Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions crates/hypercolor-core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1044,8 +1044,10 @@ impl ConfigManager {
/// effect fallback value `clear_groups` to `clear_zones`, then
/// upgraded again as v5. Schema v5 is upgraded by dropping an
/// `audio.beat_sensitivity` still at the retired 0.6 default so the
/// current default applies. Every other older or newer schema is
/// refused rather than guessed at.
/// current default applies, then upgraded again as v6. Schema v6 is
/// upgraded by dropping a `daemon.target_fps` still at the retired 30
/// default the same way. Every other older or newer schema is refused
/// rather than guessed at.
///
/// # Errors
///
Expand All @@ -1070,6 +1072,10 @@ impl ConfigManager {
}
if schema_version == 5 {
migrate_v5_to_v6(&mut document)?;
schema_version = 6;
}
if schema_version == 6 {
migrate_v6_to_v7(&mut document)?;
} else if schema_version != CURRENT_SCHEMA_VERSION {
bail!(schema_mismatch_message(schema_version));
}
Expand Down Expand Up @@ -1141,6 +1147,27 @@ fn migrate_v5_to_v6(document: &mut toml::Value) -> Result<()> {
{
audio.remove("beat_sensitivity");
}
root.insert("schema_version".to_owned(), toml::Value::Integer(6));
Ok(())
}

/// The `daemon.target_fps` default that schemas v4 through v6 shipped.
const V6_DEFAULT_TARGET_FPS: i64 = 30;

/// Schema v7 raised the render target default from 30 to
/// [`hypercolor_types::config::DEFAULT_TARGET_FPS`]. Every save rewrites the
/// whole file, so a v6 file carries 30 whether or not anyone chose it. That
/// exact value is dropped so the current default applies; any other value
/// was picked on purpose and stays.
fn migrate_v6_to_v7(document: &mut toml::Value) -> Result<()> {
let root = document
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("configuration root must be a TOML table"))?;
if let Some(daemon) = root.get_mut("daemon").and_then(toml::Value::as_table_mut)
&& daemon.get("target_fps").and_then(toml::Value::as_integer) == Some(V6_DEFAULT_TARGET_FPS)
{
daemon.remove("target_fps");
}
root.insert(
"schema_version".to_owned(),
toml::Value::Integer(i64::from(CURRENT_SCHEMA_VERSION)),
Expand Down
3 changes: 2 additions & 1 deletion crates/hypercolor-core/tests/config_persistence_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use std::time::Duration;

use hypercolor_core::config::{ConfigManager, ConfigPersistence};
use hypercolor_core::persistence::AtomicFileWriter;
use hypercolor_types::config::DEFAULT_TARGET_FPS;

fn set_mode(path: &Path, mode: u32) {
fs::set_permissions(path, fs::Permissions::from_mode(mode))
Expand Down Expand Up @@ -63,7 +64,7 @@ fn save_into_a_read_only_directory_is_live_and_lagging_until_the_retry_lands() {
.expect("the previous file stays readable")
.daemon
.target_fps,
30
DEFAULT_TARGET_FPS
);

AtomicFileWriter::new(&path)
Expand Down
2 changes: 1 addition & 1 deletion crates/hypercolor-core/tests/config_sources_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ fn pending_restart_flags_only_restart_classified_changes() {

// A live-classified exact key inside the restart-classified
// daemon section must NOT flag a restart.
manager.modify(|config| config.daemon.target_fps = 60);
manager.modify(|config| config.daemon.target_fps = 45);
assert!(
manager.pending_restart().is_empty(),
"live render knobs never flag a restart"
Expand Down
160 changes: 139 additions & 21 deletions crates/hypercolor-core/tests/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,27 @@ use std::sync::{Arc, Mutex, MutexGuard};

use hypercolor_core::config::ConfigManager;
use hypercolor_types::config::{
CURRENT_SCHEMA_VERSION, DEFAULT_BEAT_SENSITIVITY, EffectErrorFallbackPolicy, HypercolorConfig,
InteractionRoutePolicy,
CURRENT_SCHEMA_VERSION, DEFAULT_BEAT_SENSITIVITY, DEFAULT_TARGET_FPS,
EffectErrorFallbackPolicy, HypercolorConfig, InteractionRoutePolicy,
};

// ─── TOML Parsing ───────────────────────────────────────────────────────────

#[test]
fn load_minimal_toml() {
let toml = r"
schema_version = 6
schema_version = 7
";

let tmp = tempfile::NamedTempFile::new().expect("failed to create temp file");
fs::write(tmp.path(), toml).expect("failed to write temp file");

let config = ConfigManager::load(tmp.path()).expect("minimal TOML should parse without error");

assert_eq!(config.schema_version, 6);
assert_eq!(config.schema_version, 7);
// Sections should fall back to their serde defaults
assert_eq!(config.daemon.port, 9420);
assert_eq!(config.daemon.target_fps, 30);
assert_eq!(config.daemon.target_fps, 60);
assert!(config.web.enabled);
assert_eq!(config.input.daemon_route, InteractionRoutePolicy::Host);
assert_eq!(config.input.preview_route, InteractionRoutePolicy::Browser);
Expand All @@ -37,7 +37,7 @@ fn schema_v4_renames_start_profile_before_deserialization() {
ConfigManager::parse_toml("schema_version = 4\n[daemon]\nstart_profile = \"Evening\"\n")
.expect("schema v4 should migrate");

assert_eq!(config.schema_version, 6);
assert_eq!(config.schema_version, 7);
assert_eq!(config.daemon.start_scene, "Evening");
}

Expand Down Expand Up @@ -109,11 +109,126 @@ fn schema_v5_keeps_a_beat_threshold_someone_chose() {
}

#[test]
fn current_schema_keeps_a_beat_threshold_of_point_six() {
let config = ConfigManager::parse_toml("schema_version = 6\n[audio]\nbeat_sensitivity = 0.6\n")
.expect("current schema should parse");
fn schema_v6_and_later_keep_a_beat_threshold_of_point_six() {
for version in [6, CURRENT_SCHEMA_VERSION] {
let config = ConfigManager::parse_toml(&format!(
"schema_version = {version}\n[audio]\nbeat_sensitivity = 0.6\n"
))
.expect("schema v6 and later should parse");

assert!(
(config.audio.beat_sensitivity - 0.6).abs() < f32::EPSILON,
"schema {version} moved {}",
config.audio.beat_sensitivity
);
}
}

#[test]
fn schema_v6_moves_the_retired_render_target_to_the_current_one() {
for version in [4, 5, 6] {
let config = ConfigManager::parse_toml(&format!(
"schema_version = {version}\n[daemon]\ntarget_fps = 30\n"
))
.expect("older schemas should migrate");

assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
assert_eq!(
config.daemon.target_fps, DEFAULT_TARGET_FPS,
"schema {version} kept the retired render target"
);
}
}

/// A v6 save wrote the whole document, so the retired default sits on disk
/// as an explicit `target_fps = 30` that nobody chose.
#[test]
fn schema_v6_moves_the_render_target_exactly_as_a_save_wrote_it() {
let mut saved = HypercolorConfig {
schema_version: 6,
..HypercolorConfig::default()
};
saved.daemon.target_fps = 30;
let written = toml::to_string_pretty(&saved).expect("serialize a v6 save");
assert!(written.contains("target_fps = 30"), "{written}");

let config = ConfigManager::parse_toml(&written).expect("a v6 save should migrate");

assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
assert_eq!(config.daemon.target_fps, DEFAULT_TARGET_FPS);
}

#[test]
fn schema_v6_keeps_a_render_target_someone_chose() {
for chosen in [10, 20, 45, 60, 120] {
let config = ConfigManager::parse_toml(&format!(
"schema_version = 6\n[daemon]\ntarget_fps = {chosen}\n"
))
.expect("schema v6 should migrate");

assert_eq!(config.daemon.target_fps, chosen);
}
}

#[test]
fn current_schema_keeps_a_render_target_of_thirty() {
let config = ConfigManager::parse_toml(&format!(
"schema_version = {CURRENT_SCHEMA_VERSION}\n[daemon]\ntarget_fps = 30\n"
))
.expect("current schema should parse");

assert_eq!(config.daemon.target_fps, 30);
}

/// Loading never rewrites the file; the migrated value reaches disk with
/// the next save, stamped with the current schema so it is never migrated
/// again.
#[test]
fn a_migrated_render_target_reaches_disk_on_the_next_save() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
let path = dir.path().join("hypercolor.toml");
fs::write(&path, "schema_version = 6\n\n[daemon]\ntarget_fps = 30\n")
.expect("failed to write v6 config");

let manager = ConfigManager::new(path.clone()).expect("a v6 file should load");
assert_eq!(manager.get().daemon.target_fps, DEFAULT_TARGET_FPS);
assert!(manager.save().expect("save should succeed").is_durable());

let written = fs::read_to_string(&path).expect("saved config should read");
let on_disk: toml::Value = toml::from_str(&written).expect("saved config should parse");
assert_eq!(
on_disk
.get("schema_version")
.and_then(toml::Value::as_integer),
Some(i64::from(CURRENT_SCHEMA_VERSION))
);
assert_eq!(
on_disk
.get("daemon")
.and_then(|daemon| daemon.get("target_fps"))
.and_then(toml::Value::as_integer),
Some(i64::from(DEFAULT_TARGET_FPS))
);
}

/// A v4 file runs every migration in order: the start-key rename, the
/// beat threshold, and the render target.
#[test]
fn schema_v4_chains_through_every_later_migration() {
let config = ConfigManager::parse_toml(concat!(
"schema_version = 4\n\n",
"[daemon]\n",
"start_profile = \"Evening\"\n",
"target_fps = 30\n\n",
"[audio]\n",
"beat_sensitivity = 0.6\n",
))
.expect("schema v4 should migrate");

assert!((config.audio.beat_sensitivity - 0.6).abs() < f32::EPSILON);
assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
assert_eq!(config.daemon.start_scene, "Evening");
assert_eq!(config.daemon.target_fps, DEFAULT_TARGET_FPS);
assert!((config.audio.beat_sensitivity - DEFAULT_BEAT_SENSITIVITY).abs() < f32::EPSILON);
}

#[test]
Expand Down Expand Up @@ -156,10 +271,12 @@ fn outdated_schema_is_refused_and_names_the_file_and_the_fix() {

/// The hand-migration lands an old file on the oldest schema the chain
/// still upgrades, so later migrations run on it. Jumping straight to the
/// current schema would skip them and keep a retired beat threshold.
/// current schema would skip them and keep a retired beat threshold and
/// render target.
#[test]
fn following_the_hand_migration_runs_every_later_migration() {
let old = "schema_version = 3\n\n[audio]\nbeat_sensitivity = 0.6\n";
let old =
"schema_version = 3\n\n[daemon]\ntarget_fps = 30\n\n[audio]\nbeat_sensitivity = 0.6\n";
let error = ConfigManager::parse_toml(old).expect_err("schema 3 must be refused");
let rendered = format!("{error:#}");
assert!(rendered.contains("schema_version = 4"), "{rendered}");
Expand All @@ -170,14 +287,15 @@ fn following_the_hand_migration_runs_every_later_migration() {

assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
assert!((config.audio.beat_sensitivity - DEFAULT_BEAT_SENSITIVITY).abs() < f32::EPSILON);
assert_eq!(config.daemon.target_fps, DEFAULT_TARGET_FPS);
assert_eq!(config.input.daemon_route, InteractionRoutePolicy::Merge);
assert_eq!(config.input.preview_route, InteractionRoutePolicy::Browser);
}

#[test]
fn newer_schema_is_refused_as_written_by_a_newer_hypercolor() {
let tmp = tempfile::NamedTempFile::new().expect("failed to create temp file");
fs::write(tmp.path(), "schema_version = 7\n").expect("failed to write future config");
fs::write(tmp.path(), "schema_version = 8\n").expect("failed to write future config");

let error = ConfigManager::load(tmp.path())
.expect_err("a future schema must be refused, never guessed at");
Expand All @@ -187,7 +305,7 @@ fn newer_schema_is_refused_as_written_by_a_newer_hypercolor() {
rendered.contains(&tmp.path().display().to_string()),
"{rendered}"
);
assert!(rendered.contains("schema_version 7"), "{rendered}");
assert!(rendered.contains("schema_version 8"), "{rendered}");
assert!(rendered.contains("newer hypercolor"), "{rendered}");
// A future file is not an old file: no hand-migration is offered.
assert!(
Expand All @@ -202,7 +320,7 @@ fn current_schema_keeps_explicit_route_fields() {
fs::write(
tmp.path(),
concat!(
"schema_version = 6\n\n",
"schema_version = 7\n\n",
"[input]\n",
"daemon_route = \"host\"\n",
"preview_route = \"merge\"\n",
Expand All @@ -212,7 +330,7 @@ fn current_schema_keeps_explicit_route_fields() {

let config = ConfigManager::load(tmp.path()).expect("current-schema config should load");

assert_eq!(config.schema_version, 6);
assert_eq!(config.schema_version, 7);
assert_eq!(config.input.daemon_route, InteractionRoutePolicy::Host);
assert_eq!(config.input.preview_route, InteractionRoutePolicy::Merge);
}
Expand Down Expand Up @@ -339,9 +457,9 @@ fn new_with_missing_file_uses_defaults() {
ConfigManager::new(path).expect("ConfigManager should fall back to defaults gracefully");
let config = manager.get();

assert_eq!(config.schema_version, 6);
assert_eq!(config.schema_version, 7);
assert_eq!(config.daemon.port, 9420);
assert_eq!(config.daemon.target_fps, 30);
assert_eq!(config.daemon.target_fps, 60);
assert!(config.web.enabled);
}

Expand Down Expand Up @@ -760,7 +878,7 @@ mod change_stream {

assert_eq!(
drain_config_changes(&mut events),
vec![("daemon.target_fps".to_owned(), Some(json!(30)), json!(45))]
vec![("daemon.target_fps".to_owned(), Some(json!(60)), json!(45))]
);
}

Expand Down Expand Up @@ -870,7 +988,7 @@ mod change_stream {
assert_eq!(manager.get().daemon.target_fps, 50);
assert_eq!(
drain_config_changes(&mut events),
vec![("daemon.target_fps".to_owned(), Some(json!(30)), json!(50))]
vec![("daemon.target_fps".to_owned(), Some(json!(60)), json!(50))]
);
}

Expand Down Expand Up @@ -940,7 +1058,7 @@ mod change_stream {

assert_eq!(
drain_config_changes(&mut events),
vec![("daemon.target_fps".to_owned(), Some(json!(30)), json!(50))]
vec![("daemon.target_fps".to_owned(), Some(json!(60)), json!(50))]
);
}

Expand Down
2 changes: 1 addition & 1 deletion crates/hypercolor-daemon/src/durable_stores.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const ATTACHMENT: u32 = hypercolor_types::attachment::CURRENT_ATTACHMENT_SCHEMA_

/// Every durable store this release reads or writes.
pub const DURABLE_STORES: &[DurableStore] = &[
// Reads schemas 4 through 6; 4 and 5 are upgraded in memory
// Reads schemas 4 through 7; 4, 5, and 6 are upgraded in memory
// (hypercolor_core::config::ConfigManager::parse_toml).
DurableStore {
name: "config",
Expand Down
7 changes: 5 additions & 2 deletions crates/hypercolor-daemon/tests/daemon_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ async fn config_loading_defaults_have_correct_schema() {
.boot;

assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
assert_eq!(config.daemon.target_fps, 30);
assert_eq!(config.daemon.target_fps, 60);
assert_eq!(config.daemon.port, 9420);
assert_eq!(config.daemon.listen_address, "127.0.0.1");
assert_eq!(config.daemon.canvas_width, DEFAULT_CANVAS_WIDTH);
Expand Down Expand Up @@ -645,7 +645,10 @@ async fn daemon_render_loop_uses_configured_fps() {
let mut config = default_config();
config.daemon.target_fps = 30;

let toml_str = "schema_version = 5\n[daemon]\ntarget_fps = 30\n";
// Only a current-schema file keeps 30; an older one carries the
// retired default and loads at the new one.
let toml_str =
format!("schema_version = {CURRENT_SCHEMA_VERSION}\n[daemon]\ntarget_fps = 30\n");
let mut temp = NamedTempFile::new().expect("create temp");
temp.write_all(toml_str.as_bytes()).expect("write");
temp.flush().expect("flush");
Expand Down
2 changes: 1 addition & 1 deletion crates/hypercolor-daemon/tests/durable_store_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ fn enforced_versions_come_from_the_stores_own_constants() {
hypercolor_daemon::device_settings::DEVICE_SETTINGS_SCHEMA_VERSION
);
for (name, min, max) in [
("config", 4, 6),
("config", 4, 7),
("scenes", 2, 2),
("device-settings", 1, 3),
("device-aliases", 2, 2),
Expand Down
Loading
Loading