Initial commit

This commit is contained in:
2026-04-17 01:33:23 +02:00
commit 1f6543bd7a
28 changed files with 2556 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
[package]
name = "infinity_config"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true
toml.workspace = true

View File

@@ -0,0 +1,27 @@
mod model;
mod validation;
pub use model::*;
pub use validation::*;
use std::{fs, path::Path};
#[derive(Debug, thiserror::Error)]
pub enum ProjectLoadError {
#[error("failed to read project config: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse project config: {0}")]
Parse(#[from] toml::de::Error),
}
impl ProjectConfig {
pub fn from_toml_str(raw: &str) -> Result<Self, toml::de::Error> {
toml::from_str(raw)
}
}
pub fn load_project_from_path(path: impl AsRef<Path>) -> Result<ProjectConfig, ProjectLoadError> {
let raw = fs::read_to_string(path)?;
ProjectConfig::from_toml_str(&raw).map_err(ProjectLoadError::from)
}

View File

@@ -0,0 +1,261 @@
use serde::{Deserialize, Serialize};
pub const REQUIRED_NODE_COUNT: usize = 6;
pub const REQUIRED_OUTPUTS_PER_NODE: usize = 3;
pub const REQUIRED_LED_COUNT_PER_OUTPUT: u16 = 106;
pub const REQUIRED_TOTAL_OUTPUTS: usize = REQUIRED_NODE_COUNT * REQUIRED_OUTPUTS_PER_NODE;
fn default_expected_node_count() -> usize {
REQUIRED_NODE_COUNT
}
fn default_outputs_per_node() -> usize {
REQUIRED_OUTPUTS_PER_NODE
}
fn default_led_count_per_output() -> u16 {
REQUIRED_LED_COUNT_PER_OUTPUT
}
fn default_true() -> bool {
true
}
fn default_transition_ms() -> u32 {
150
}
fn default_network_send_hz() -> u16 {
60
}
fn default_logic_hz() -> u16 {
120
}
fn default_preview_hz() -> u16 {
15
}
fn default_heartbeat_hz() -> u16 {
5
}
fn default_hold_last_frame_ms() -> u32 {
1500
}
fn default_brightness_limit() -> f32 {
0.35
}
fn default_start_brightness() -> f32 {
0.2
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectConfig {
pub metadata: ProjectMetadata,
pub topology: TopologyConfig,
#[serde(default)]
pub transport_profiles: Vec<TransportProfileConfig>,
#[serde(default)]
pub safety_profiles: Vec<SafetyProfileConfig>,
#[serde(default)]
pub presets: Vec<PresetConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectMetadata {
pub project_name: String,
pub schema_version: u32,
pub default_transport_profile: String,
pub default_safety_profile: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TopologyConfig {
#[serde(default = "default_expected_node_count")]
pub expected_node_count: usize,
#[serde(default = "default_outputs_per_node")]
pub outputs_per_node: usize,
#[serde(default = "default_led_count_per_output")]
pub leds_per_output: u16,
pub nodes: Vec<NodeConfig>,
#[serde(default)]
pub layout_panels: Vec<LayoutPanelConfig>,
#[serde(default)]
pub groups: Vec<GroupConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeConfig {
pub node_id: String,
pub display_name: String,
pub network: NodeNetworkConfig,
pub outputs: Vec<PanelOutputConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeNetworkConfig {
pub reserved_ip: Option<String>,
pub telemetry_label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PanelOutputConfig {
pub panel_position: PanelPosition,
pub physical_output_name: String,
pub driver_channel: DriverChannelRef,
pub led_count: u16,
pub direction: LedDirection,
pub color_order: ColorOrder,
#[serde(default = "default_true")]
pub enabled: bool,
pub validation_state: ValidationState,
pub logical_panel_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DriverChannelRef {
pub kind: DriverKind,
pub reference: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum PanelPosition {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum LedDirection {
Forward,
Reverse,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ColorOrder {
Rgb,
Rbg,
Grb,
Gbr,
Brg,
Bgr,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum DriverKind {
PendingValidation,
Gpio,
RmtChannel,
I2sLane,
UartPort,
SpiBus,
ExternalDriver,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ValidationState {
PendingHardwareValidation,
Validated,
Retired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct PanelRef {
pub node_id: String,
pub panel_position: PanelPosition,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LayoutPanelConfig {
pub node_id: String,
pub panel_position: PanelPosition,
pub row: usize,
pub column: usize,
#[serde(default)]
pub rotation_degrees: i16,
#[serde(default)]
pub mirror_x: bool,
#[serde(default)]
pub mirror_y: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GroupConfig {
pub group_id: String,
#[serde(default)]
pub tags: Vec<String>,
pub members: Vec<PanelRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TransportProfileConfig {
pub profile_id: String,
pub mode: TransportMode,
#[serde(default = "default_logic_hz")]
pub logic_hz: u16,
#[serde(default = "default_network_send_hz")]
pub network_send_hz: u16,
#[serde(default = "default_preview_hz")]
pub preview_hz: u16,
#[serde(default = "default_heartbeat_hz")]
pub heartbeat_hz: u16,
#[serde(default)]
pub ddp_compatibility: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TransportMode {
DistributedScene,
FrameStreaming,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SafetyProfileConfig {
pub profile_id: String,
#[serde(default = "default_brightness_limit")]
pub master_brightness_limit: f32,
#[serde(default = "default_start_brightness")]
pub default_start_brightness: f32,
#[serde(default)]
pub allow_strobe: bool,
#[serde(default)]
pub max_strobe_hz: Option<f32>,
#[serde(default = "default_hold_last_frame_ms")]
pub hold_last_frame_ms: u32,
#[serde(default)]
pub fallback_preset_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PresetConfig {
pub preset_id: String,
pub scene: SceneConfig,
#[serde(default = "default_transition_ms")]
pub transition_ms: u32,
#[serde(default)]
pub target_group: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneConfig {
pub effect: String,
pub seed: u64,
#[serde(default)]
pub palette: Vec<String>,
#[serde(default)]
pub speed: f32,
#[serde(default)]
pub intensity: f32,
#[serde(default)]
pub blackout: bool,
}

View File

@@ -0,0 +1,660 @@
use crate::{
DriverKind, GroupConfig, PanelPosition, PanelRef, ProjectConfig, ValidationState,
REQUIRED_LED_COUNT_PER_OUTPUT, REQUIRED_NODE_COUNT, REQUIRED_OUTPUTS_PER_NODE,
REQUIRED_TOTAL_OUTPUTS,
};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationMode {
Structural,
Activation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationSeverity {
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationIssue {
pub severity: ValidationSeverity,
pub code: &'static str,
pub path: String,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ValidationReport {
pub issues: Vec<ValidationIssue>,
}
impl ValidationReport {
pub fn is_ok(&self) -> bool {
self.error_count() == 0
}
pub fn error_count(&self) -> usize {
self.issues
.iter()
.filter(|issue| issue.severity == ValidationSeverity::Error)
.count()
}
pub fn warning_count(&self) -> usize {
self.issues
.iter()
.filter(|issue| issue.severity == ValidationSeverity::Warning)
.count()
}
pub fn push(
&mut self,
severity: ValidationSeverity,
code: &'static str,
path: impl Into<String>,
message: impl Into<String>,
) {
self.issues.push(ValidationIssue {
severity,
code,
path: path.into(),
message: message.into(),
});
}
}
impl ProjectConfig {
pub fn validate(&self, mode: ValidationMode) -> ValidationReport {
let mut report = ValidationReport::default();
self.validate_profiles(&mut report);
self.validate_topology(mode, &mut report);
self.validate_groups(&mut report);
self.validate_safety_profiles(&mut report);
self.validate_presets(&mut report);
report
}
fn validate_profiles(&self, report: &mut ValidationReport) {
let transport_profiles: BTreeSet<_> = self
.transport_profiles
.iter()
.map(|profile| profile.profile_id.as_str())
.collect();
let safety_profiles: BTreeSet<_> = self
.safety_profiles
.iter()
.map(|profile| profile.profile_id.as_str())
.collect();
if !transport_profiles.contains(self.metadata.default_transport_profile.as_str()) {
report.push(
ValidationSeverity::Error,
"missing_default_transport_profile",
"metadata.default_transport_profile",
format!(
"default transport profile '{}' does not exist",
self.metadata.default_transport_profile
),
);
}
if !safety_profiles.contains(self.metadata.default_safety_profile.as_str()) {
report.push(
ValidationSeverity::Error,
"missing_default_safety_profile",
"metadata.default_safety_profile",
format!(
"default safety profile '{}' does not exist",
self.metadata.default_safety_profile
),
);
}
}
fn validate_topology(&self, mode: ValidationMode, report: &mut ValidationReport) {
let topology = &self.topology;
if topology.expected_node_count != REQUIRED_NODE_COUNT {
report.push(
ValidationSeverity::Warning,
"expected_node_count_override",
"topology.expected_node_count",
format!(
"expected node count is {}, project overrides to {}",
REQUIRED_NODE_COUNT, topology.expected_node_count
),
);
}
if topology.outputs_per_node != REQUIRED_OUTPUTS_PER_NODE {
report.push(
ValidationSeverity::Warning,
"outputs_per_node_override",
"topology.outputs_per_node",
format!(
"expected outputs per node is {}, project overrides to {}",
REQUIRED_OUTPUTS_PER_NODE, topology.outputs_per_node
),
);
}
if topology.leds_per_output != REQUIRED_LED_COUNT_PER_OUTPUT {
report.push(
ValidationSeverity::Error,
"invalid_leds_per_output",
"topology.leds_per_output",
format!(
"fixed system requirement is {} LEDs per output",
REQUIRED_LED_COUNT_PER_OUTPUT
),
);
}
if topology.nodes.len() != topology.expected_node_count {
report.push(
ValidationSeverity::Error,
"unexpected_node_count",
"topology.nodes",
format!(
"expected {} nodes, found {}",
topology.expected_node_count,
topology.nodes.len()
),
);
}
let mut node_ids = BTreeSet::new();
let mut panel_refs = BTreeSet::new();
let mut output_count = 0usize;
for (node_index, node) in topology.nodes.iter().enumerate() {
let node_path = format!("topology.nodes[{node_index}]");
if !node_ids.insert(node.node_id.as_str()) {
report.push(
ValidationSeverity::Error,
"duplicate_node_id",
format!("{node_path}.node_id"),
format!("duplicate node id '{}'", node.node_id),
);
}
if node.outputs.len() != topology.outputs_per_node {
report.push(
ValidationSeverity::Error,
"unexpected_output_count",
format!("{node_path}.outputs"),
format!(
"expected {} outputs for node '{}', found {}",
topology.outputs_per_node,
node.node_id,
node.outputs.len()
),
);
}
let mut panel_positions = BTreeSet::new();
let mut driver_refs = BTreeSet::new();
for (output_index, output) in node.outputs.iter().enumerate() {
output_count += 1;
let output_path = format!("{node_path}.outputs[{output_index}]");
panel_positions.insert(output.panel_position.clone());
let panel_ref = PanelRef {
node_id: node.node_id.clone(),
panel_position: output.panel_position.clone(),
};
if !panel_refs.insert(panel_ref) {
report.push(
ValidationSeverity::Error,
"duplicate_panel_position",
format!("{output_path}.panel_position"),
format!(
"node '{}' has multiple '{}' outputs",
node.node_id,
display_panel_position(&output.panel_position)
),
);
}
if output.led_count != topology.leds_per_output {
report.push(
ValidationSeverity::Error,
"invalid_led_count",
format!("{output_path}.led_count"),
format!(
"expected {} LEDs, found {}",
topology.leds_per_output, output.led_count
),
);
}
let driver_key = format!(
"{}::{}",
output.driver_channel.kind_label(),
output.driver_channel.reference
);
if !driver_refs.insert(driver_key.clone()) {
report.push(
ValidationSeverity::Error,
"duplicate_driver_reference",
format!("{output_path}.driver_channel.reference"),
format!(
"node '{}' reuses driver reference '{}'",
node.node_id, driver_key
),
);
}
if mode == ValidationMode::Activation && output.enabled {
if output.driver_channel.kind == DriverKind::PendingValidation {
report.push(
ValidationSeverity::Error,
"pending_driver_validation",
format!("{output_path}.driver_channel.kind"),
format!(
"output '{}' cannot be activated while driver kind is pending validation",
output.physical_output_name
),
);
}
if output.validation_state != ValidationState::Validated {
report.push(
ValidationSeverity::Error,
"pending_output_validation",
format!("{output_path}.validation_state"),
format!(
"output '{}' is not hardware-validated yet",
output.physical_output_name
),
);
}
}
}
for required_position in [
PanelPosition::Top,
PanelPosition::Middle,
PanelPosition::Bottom,
] {
if !panel_positions.contains(&required_position) {
report.push(
ValidationSeverity::Error,
"missing_panel_position",
format!("{node_path}.outputs"),
format!(
"node '{}' is missing the '{}' output",
node.node_id,
display_panel_position(&required_position)
),
);
}
}
}
if output_count != REQUIRED_TOTAL_OUTPUTS {
report.push(
ValidationSeverity::Warning,
"unexpected_total_output_count",
"topology.nodes",
format!(
"system target is {} total outputs, current config defines {}",
REQUIRED_TOTAL_OUTPUTS, output_count
),
);
}
let known_panels = panel_refs;
let mut layout_refs = BTreeSet::new();
for (index, panel) in topology.layout_panels.iter().enumerate() {
let panel_ref = PanelRef {
node_id: panel.node_id.clone(),
panel_position: panel.panel_position.clone(),
};
if !known_panels.contains(&panel_ref) {
report.push(
ValidationSeverity::Error,
"unknown_layout_panel_reference",
format!("topology.layout_panels[{index}]"),
format!(
"layout references unknown panel '{}:{}'",
panel.node_id,
display_panel_position(&panel.panel_position)
),
);
}
if !layout_refs.insert((panel.node_id.as_str(), &panel.panel_position)) {
report.push(
ValidationSeverity::Error,
"duplicate_layout_panel_reference",
format!("topology.layout_panels[{index}]"),
format!(
"layout contains a duplicate reference for '{}:{}'",
panel.node_id,
display_panel_position(&panel.panel_position)
),
);
}
}
}
fn validate_groups(&self, report: &mut ValidationReport) {
let known_panels: BTreeSet<_> = self
.topology
.nodes
.iter()
.flat_map(|node| {
node.outputs.iter().map(move |output| PanelRef {
node_id: node.node_id.clone(),
panel_position: output.panel_position.clone(),
})
})
.collect();
let mut group_ids = BTreeSet::new();
for (group_index, group) in self.topology.groups.iter().enumerate() {
if !group_ids.insert(group.group_id.as_str()) {
report.push(
ValidationSeverity::Error,
"duplicate_group_id",
format!("topology.groups[{group_index}].group_id"),
format!("duplicate group id '{}'", group.group_id),
);
}
validate_group_members(group, group_index, &known_panels, report);
}
}
fn validate_safety_profiles(&self, report: &mut ValidationReport) {
let preset_ids: BTreeSet<_> = self.presets.iter().map(|preset| preset.preset_id.as_str()).collect();
for (index, profile) in self.safety_profiles.iter().enumerate() {
if !(0.0..=1.0).contains(&profile.master_brightness_limit) {
report.push(
ValidationSeverity::Error,
"invalid_brightness_limit",
format!("safety_profiles[{index}].master_brightness_limit"),
"master brightness limit must be between 0.0 and 1.0",
);
}
if !(0.0..=profile.master_brightness_limit).contains(&profile.default_start_brightness) {
report.push(
ValidationSeverity::Error,
"invalid_start_brightness",
format!("safety_profiles[{index}].default_start_brightness"),
"default start brightness must not exceed the master brightness limit",
);
}
if let Some(preset_id) = &profile.fallback_preset_id {
if !preset_ids.contains(preset_id.as_str()) {
report.push(
ValidationSeverity::Error,
"unknown_fallback_preset",
format!("safety_profiles[{index}].fallback_preset_id"),
format!("fallback preset '{}' does not exist", preset_id),
);
}
}
}
}
fn validate_presets(&self, report: &mut ValidationReport) {
let group_ids: BTreeSet<_> = self
.topology
.groups
.iter()
.map(|group| group.group_id.as_str())
.collect();
let mut preset_ids = BTreeSet::new();
for (index, preset) in self.presets.iter().enumerate() {
if !preset_ids.insert(preset.preset_id.as_str()) {
report.push(
ValidationSeverity::Error,
"duplicate_preset_id",
format!("presets[{index}].preset_id"),
format!("duplicate preset id '{}'", preset.preset_id),
);
}
if let Some(group_id) = &preset.target_group {
if !group_ids.contains(group_id.as_str()) {
report.push(
ValidationSeverity::Error,
"unknown_target_group",
format!("presets[{index}].target_group"),
format!("preset references unknown group '{}'", group_id),
);
}
}
}
}
}
impl crate::DriverChannelRef {
fn kind_label(&self) -> &'static str {
match self.kind {
DriverKind::PendingValidation => "pending_validation",
DriverKind::Gpio => "gpio",
DriverKind::RmtChannel => "rmt_channel",
DriverKind::I2sLane => "i2s_lane",
DriverKind::UartPort => "uart_port",
DriverKind::SpiBus => "spi_bus",
DriverKind::ExternalDriver => "external_driver",
}
}
}
fn validate_group_members(
group: &GroupConfig,
group_index: usize,
known_panels: &BTreeSet<PanelRef>,
report: &mut ValidationReport,
) {
let mut unique_members = BTreeSet::new();
for (member_index, member) in group.members.iter().enumerate() {
if !known_panels.contains(member) {
report.push(
ValidationSeverity::Error,
"unknown_group_member",
format!("topology.groups[{group_index}].members[{member_index}]"),
format!(
"group '{}' references unknown panel '{}:{}'",
group.group_id,
member.node_id,
display_panel_position(&member.panel_position)
),
);
}
if !unique_members.insert((member.node_id.as_str(), &member.panel_position)) {
report.push(
ValidationSeverity::Error,
"duplicate_group_member",
format!("topology.groups[{group_index}].members[{member_index}]"),
format!(
"group '{}' contains '{}' more than once",
group.group_id,
panel_ref_label(member)
),
);
}
}
}
fn panel_ref_label(panel: &PanelRef) -> String {
format!(
"{}:{}",
panel.node_id,
display_panel_position(&panel.panel_position)
)
}
fn display_panel_position(position: &PanelPosition) -> &'static str {
match position {
PanelPosition::Top => "top",
PanelPosition::Middle => "middle",
PanelPosition::Bottom => "bottom",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ColorOrder, DriverChannelRef, LedDirection, NodeConfig, NodeNetworkConfig, PanelOutputConfig,
PanelPosition, PresetConfig, ProjectMetadata, SceneConfig, TopologyConfig, TransportMode,
TransportProfileConfig,
};
fn build_output(position: PanelPosition, label: &str, driver_kind: DriverKind) -> PanelOutputConfig {
PanelOutputConfig {
panel_position: position,
physical_output_name: label.to_string(),
driver_channel: DriverChannelRef {
kind: driver_kind,
reference: label.to_string(),
},
led_count: 106,
direction: LedDirection::Forward,
color_order: ColorOrder::Grb,
enabled: true,
validation_state: ValidationState::Validated,
logical_panel_name: None,
}
}
fn build_project() -> ProjectConfig {
let mut nodes = Vec::new();
for index in 1..=6 {
nodes.push(NodeConfig {
node_id: format!("node-{index:02}"),
display_name: format!("Node {index:02}"),
network: NodeNetworkConfig {
reserved_ip: Some(format!("192.168.40.1{index:02}")),
telemetry_label: None,
},
outputs: vec![
build_output(PanelPosition::Top, "GPIO_1", DriverKind::Gpio),
build_output(PanelPosition::Middle, "GPIO_2", DriverKind::Gpio),
build_output(PanelPosition::Bottom, "GPIO_3", DriverKind::Gpio),
],
});
}
ProjectConfig {
metadata: ProjectMetadata {
project_name: "Test".to_string(),
schema_version: 1,
default_transport_profile: "default-scene".to_string(),
default_safety_profile: "live-safe".to_string(),
},
topology: TopologyConfig {
expected_node_count: 6,
outputs_per_node: 3,
leds_per_output: 106,
nodes,
layout_panels: Vec::new(),
groups: vec![GroupConfig {
group_id: "all".to_string(),
tags: vec!["global".to_string()],
members: vec![
PanelRef {
node_id: "node-01".to_string(),
panel_position: PanelPosition::Top,
},
PanelRef {
node_id: "node-01".to_string(),
panel_position: PanelPosition::Middle,
},
],
}],
},
transport_profiles: vec![TransportProfileConfig {
profile_id: "default-scene".to_string(),
mode: TransportMode::DistributedScene,
logic_hz: 120,
network_send_hz: 60,
preview_hz: 15,
heartbeat_hz: 5,
ddp_compatibility: false,
}],
safety_profiles: vec![SafetyProfileConfig {
profile_id: "live-safe".to_string(),
master_brightness_limit: 0.3,
default_start_brightness: 0.2,
allow_strobe: false,
max_strobe_hz: None,
hold_last_frame_ms: 1200,
fallback_preset_id: Some("safe-blue".to_string()),
}],
presets: vec![PresetConfig {
preset_id: "safe-blue".to_string(),
scene: SceneConfig {
effect: "solid_color".to_string(),
seed: 7,
palette: vec!["#003bff".to_string()],
speed: 0.0,
intensity: 1.0,
blackout: false,
},
transition_ms: 150,
target_group: None,
}],
}
}
#[test]
fn accepts_structurally_valid_project() {
let project = build_project();
let report = project.validate(ValidationMode::Structural);
assert!(report.is_ok(), "{report:#?}");
}
#[test]
fn rejects_wrong_led_count() {
let mut project = build_project();
project.topology.nodes[0].outputs[0].led_count = 105;
let report = project.validate(ValidationMode::Structural);
assert!(!report.is_ok());
assert!(report
.issues
.iter()
.any(|issue| issue.code == "invalid_led_count"));
}
#[test]
fn rejects_duplicate_driver_refs() {
let mut project = build_project();
project.topology.nodes[0].outputs[1].driver_channel.reference =
project.topology.nodes[0].outputs[0].driver_channel.reference.clone();
let report = project.validate(ValidationMode::Structural);
assert!(!report.is_ok());
assert!(report
.issues
.iter()
.any(|issue| issue.code == "duplicate_driver_reference"));
}
#[test]
fn activation_requires_hardware_validation() {
let mut project = build_project();
project.topology.nodes[0].outputs[0].driver_channel.kind = DriverKind::PendingValidation;
project.topology.nodes[0].outputs[0].validation_state =
ValidationState::PendingHardwareValidation;
let report = project.validate(ValidationMode::Activation);
assert!(!report.is_ok());
assert!(report
.issues
.iter()
.any(|issue| issue.code == "pending_driver_validation"));
assert!(report
.issues
.iter()
.any(|issue| issue.code == "pending_output_validation"));
}
}