use std::collections::BTreeSet;
use std::time::Duration;
use igs_iec_tase2::{DeviceConfig, TaseServerConfig};
use igs_iec_tase2_link::association::ServerPolicy;
use igs_iec_tase2_link::bilateral::{
BilateralTable, BilateralTables, DeviceAccess, ObjectAccess, ObjectName, Permission, Scope,
};
use igs_iec_tase2_link::device::DeviceClass;
use igs_iec_tase2_link::transfer::Policy;
use igs_iec_tase2_link::version::{Versions, STANDARD_VERSION};
use igs_iec_tase2_model::generated::iso503::VisibleString32;
use igs_iec_tase2_model::generated::iso802::SBOCheckBackName;
use igs_iec_tase2_model::mms::MmsValue;
use igs_iso9506_mms::server::ServerConfig;
use igs_iso9506_mms::vmd::{Identity, MemoryVariable, Name, Target, Vmd};
use igs_iso9506_mms_codec::generated::types::{
Data, TypeDescription, TypeDescriptionFloatingPoint, Unsigned8,
};
use igs_iso9506_mms_link::config::LinkConfig;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub server: ServerSection,
#[serde(default)]
pub identity: IdentitySection,
#[serde(default, rename = "domain")]
pub domains: Vec<DomainSection>,
#[serde(default, rename = "point")]
pub points: Vec<PointSection>,
#[serde(default, rename = "data-set")]
pub data_sets: Vec<DataSetSection>,
#[serde(default, rename = "transfer-set")]
pub transfer_sets: Vec<TransferSetSection>,
#[serde(default, rename = "device")]
pub devices: Vec<DeviceSection>,
#[serde(default, rename = "table")]
pub tables: Vec<TableSection>,
#[serde(default, rename = "simulate")]
pub simulations: Vec<SimulationSection>,
#[serde(default, rename = "information")]
pub information: Vec<InformationSection>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ServerSection {
pub poll_ms: u64,
pub ack_timeout_s: u64,
pub expected_send_delay_ms: u64,
pub data_set_capacity: usize,
}
impl Default for ServerSection {
fn default() -> Self {
Self {
poll_ms: 50,
ack_timeout_s: 30,
expected_send_delay_ms: 0,
data_set_capacity: 64,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct IdentitySection {
pub vendor: String,
pub model: String,
pub revision: String,
}
impl Default for IdentitySection {
fn default() -> Self {
Self {
vendor: "igs".to_string(),
model: "igstase2 demo".to_string(),
revision: "0.1".to_string(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DomainSection {
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PointKind {
Integer,
Unsigned,
Float,
Boolean,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PointSection {
pub domain: String,
pub name: String,
#[serde(rename = "type")]
pub kind: PointKind,
#[serde(default)]
pub bits: Option<u32>,
pub value: toml::Value,
#[serde(default)]
pub writable: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DataSetSection {
pub domain: String,
pub name: String,
pub members: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DeviceClassKind {
Sbo,
NonSbo,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceSection {
pub domain: String,
pub name: String,
pub class: DeviceClassKind,
#[serde(default)]
pub closing_commands: Vec<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransferSetSection {
pub domain: String,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AccessKind {
DataValue,
DataSet,
TransferSet,
Device,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AccessSection {
pub kind: AccessKind,
pub name: String,
pub permissions: Vec<String>,
#[serde(default)]
pub interval_ms: Option<u64>,
#[serde(default)]
pub check_back_id: Option<i64>,
#[serde(default)]
pub timeout_s: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TableSection {
pub designation: String,
pub table_id: String,
pub domain: String,
pub application_references: Vec<String>,
#[serde(default)]
pub data_set_creation: bool,
#[serde(default)]
pub initiator: bool,
#[serde(default)]
pub access: Vec<AccessSection>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SimulationKind {
Ramp,
Toggle,
Sine,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SimulationSection {
pub domain: String,
pub name: String,
pub kind: SimulationKind,
pub period_ms: u64,
#[serde(default)]
pub min: f64,
#[serde(default)]
pub max: f64,
#[serde(default)]
pub step: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigError(pub String);
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ConfigError {}
fn invalid(context: &str, message: impl std::fmt::Display) -> ConfigError {
ConfigError(format!("{context}: {message}"))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InformationSection {
pub info_reference: i64,
pub local_reference: i64,
pub stream: String,
pub period_ms: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct InformationSource {
pub info_reference: i64,
pub local_reference: i64,
pub stream: Vec<u8>,
pub period: Duration,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Simulation {
pub name: ObjectName,
pub kind: SimulationKind,
pub period: Duration,
pub min: f64,
pub max: f64,
pub step: f64,
pub point: PointKind,
}
pub struct Built {
pub vmd: Vmd,
pub server: TaseServerConfig,
pub simulations: Vec<Simulation>,
pub information: Vec<InformationSource>,
}
fn scope_of(domain: &str) -> Scope {
Scope::Domain(domain.to_string())
}
fn object(domain: &str, name: &str) -> ObjectName {
ObjectName {
scope: scope_of(domain),
name: name.to_string(),
}
}
fn permission(text: &str, context: &str) -> Result<Permission, ConfigError> {
Ok(match text {
"visibility" => Permission::Visibility,
"get-data-value" => Permission::GetDataValue,
"set-data-value" => Permission::SetDataValue,
"get-data-value-type" => Permission::GetDataValueType,
"delete-data-set" => Permission::DeleteDataSet,
"get-data-set-element-values" => Permission::GetDataSetElementValues,
"set-data-set-element-values" => Permission::SetDataSetElementValues,
"start-transfer" => Permission::StartTransfer,
"stop-transfer" => Permission::StopTransfer,
"select" => Permission::Select,
"operate" => Permission::Operate,
"set-tag" => Permission::SetTag,
other => {
return Err(invalid(
context,
format!("the permission {other:?} is not known"),
))
}
})
}
fn typed(point: &PointSection) -> Result<(TypeDescription, Data), ConfigError> {
let context = format!("point {}/{}", point.domain, point.name);
if point.kind != PointKind::Integer && point.kind != PointKind::Unsigned && point.bits.is_some()
{
return Err(invalid(&context, "bits is given for an integer only"));
}
match point.kind {
PointKind::Integer | PointKind::Unsigned => {
let bits = point.bits.unwrap_or(32);
if !(1..=127).contains(&bits) {
return Err(invalid(
&context,
format!("bits is {bits}; it must be 1 to 127"),
));
}
let value = point
.value
.as_integer()
.ok_or_else(|| invalid(&context, "the value is not an integer"))?;
let value = i128::from(value);
let half = 1i128
.checked_shl(bits.saturating_sub(1))
.unwrap_or(i128::MAX);
let span = 1i128.checked_shl(bits).unwrap_or(i128::MAX);
let (low, high) = if point.kind == PointKind::Integer {
(0i128.saturating_sub(half), half.saturating_sub(1))
} else {
(0, span.saturating_sub(1))
};
if value < low || value > high {
return Err(invalid(
&context,
format!("the value {value} does not fit {bits} bits"),
));
}
let width = Unsigned8(i64::from(bits));
if point.kind == PointKind::Integer {
Ok((TypeDescription::Integer(width), Data::Integer(value)))
} else {
let unsigned =
u128::try_from(value).map_err(|_| invalid(&context, "a negative unsigned"))?;
Ok((TypeDescription::Unsigned(width), Data::Unsigned(unsigned)))
}
}
PointKind::Float => {
let value = match &point.value {
toml::Value::Float(value) => *value,
toml::Value::Integer(value) => *value as f64,
_ => return Err(invalid(&context, "the value is not a number")),
};
if !value.is_finite() {
return Err(invalid(&context, "the value is not finite"));
}
let ty = TypeDescription::FloatingPoint(TypeDescriptionFloatingPoint {
format_width: Unsigned8(32),
exponent_width: Unsigned8(8),
});
let data =
igs_iec_tase2_model::generated::iso802::clause_6_2_1::DataReal(value).to_mms();
Ok((ty, data))
}
PointKind::Boolean => {
let value = point
.value
.as_bool()
.ok_or_else(|| invalid(&context, "the value is not a boolean"))?;
Ok((TypeDescription::Boolean, Data::Boolean(value)))
}
}
}
pub fn build(config: &Config, max_associations: usize) -> Result<Built, ConfigError> {
let domains: BTreeSet<&str> = config
.domains
.iter()
.map(|domain| domain.name.as_str())
.collect();
let domain_known = |domain: &str, context: &str| {
if domains.contains(domain) {
Ok(())
} else {
Err(invalid(
context,
format!("the domain {domain:?} is not declared"),
))
}
};
let identity = Identity {
vendor: config.identity.vendor.clone(),
model: config.identity.model.clone(),
revision: config.identity.revision.clone(),
};
let mut vmd = Vmd::new(identity);
for domain in &config.domains {
let name = Name::new(&domain.name)
.map_err(|e| invalid(&format!("domain {:?}", domain.name), e))?;
vmd.add_domain(name)
.map_err(|e| invalid(&format!("domain {:?}", domain.name), format!("{e:?}")))?;
}
let mut points = BTreeSet::new();
for point in &config.points {
let context = format!("point {}/{}", point.domain, point.name);
domain_known(&point.domain, &context)?;
if !points.insert(object(&point.domain, &point.name)) {
return Err(invalid(&context, "the point is declared twice"));
}
let target = Target::in_domain(
Name::new(&point.domain).map_err(|e| invalid(&context, e))?,
Name::new(&point.name).map_err(|e| invalid(&context, e))?,
);
let (ty, value) = typed(point)?;
let variable = MemoryVariable::new(ty, value, point.writable)
.map_err(|e| invalid(&context, format!("{e:?}")))?;
vmd.add_variable(&target, Box::new(variable))
.map_err(|e| invalid(&context, format!("{e:?}")))?;
}
let mut device_configs = Vec::new();
let mut device_names = BTreeSet::new();
for section in &config.devices {
let context = format!("device {}/{}", section.domain, section.name);
domain_known(§ion.domain, &context)?;
let name = object(§ion.domain, §ion.name);
if !device_names.insert(name.clone()) {
return Err(invalid(&context, "the device is declared twice"));
}
device_configs.push(DeviceConfig {
name,
class: match section.class {
DeviceClassKind::Sbo => DeviceClass::Sbo,
DeviceClassKind::NonSbo => DeviceClass::NonSbo,
},
closing_commands: section.closing_commands.clone(),
});
}
let mut data_sets = Vec::new();
let mut data_set_names = BTreeSet::new();
for set in &config.data_sets {
let context = format!("data set {}/{}", set.domain, set.name);
domain_known(&set.domain, &context)?;
let name = object(&set.domain, &set.name);
if !data_set_names.insert(name.clone()) {
return Err(invalid(&context, "the data set is declared twice"));
}
let mut members = Vec::new();
for member in &set.members {
let member = object(&set.domain, member);
if !points.contains(&member) {
return Err(invalid(
&context,
format!("the member {} is not a point", member.name),
));
}
members.push(member);
}
data_sets.push((name, members));
}
let mut transfer_sets = BTreeSet::new();
for transfer in &config.transfer_sets {
let context = format!("transfer set {}/{}", transfer.domain, transfer.name);
domain_known(&transfer.domain, &context)?;
transfer_sets.insert(object(&transfer.domain, &transfer.name));
}
let mut tables = BilateralTables::new();
let mut policy = ServerPolicy::new();
for table in &config.tables {
let context = format!("table {}", table.designation);
domain_known(&table.domain, &context)?;
let table_id = VisibleString32::new(&table.table_id)
.map_err(|e| invalid(&context, format!("the table identifier: {e:?}")))?;
let mut built = BilateralTable::new(
table.designation.as_str(),
Versions {
table_id,
tase2: STANDARD_VERSION,
},
table.domain.as_str(),
);
for reference in &table.application_references {
built = built.with_application_reference(reference.as_str());
}
if table.data_set_creation {
built = built.with_data_set_creation();
}
for access in &table.access {
let object_name = object(&table.domain, &access.name);
let context = format!("{context}: access to {}", access.name);
let mut permitted = BTreeSet::new();
for text in &access.permissions {
permitted.insert(permission(text, &context)?);
}
let objects = ObjectAccess {
permitted,
minimum_interval: access.interval_ms.map(Duration::from_millis),
};
built = match access.kind {
AccessKind::DataValue => {
if !points.contains(&object_name) {
return Err(invalid(&context, "it is not a point"));
}
built.with_data_value(object_name, objects)
}
AccessKind::DataSet => {
if !data_set_names.contains(&object_name) {
return Err(invalid(&context, "it is not a data set"));
}
built.with_data_set(object_name, objects)
}
AccessKind::TransferSet => {
if !transfer_sets.contains(&object_name) {
return Err(invalid(&context, "it is not a transfer set"));
}
built.with_transfer_set(object_name, objects)
}
AccessKind::Device => {
if !device_names.contains(&object_name) {
return Err(invalid(&context, "it is not a device"));
}
let check_back_id = access
.check_back_id
.ok_or_else(|| invalid(&context, "a device needs its check-back-id"))?;
let timeout_s = access
.timeout_s
.ok_or_else(|| invalid(&context, "a device needs its timeout-s"))?;
let check_back_id = SBOCheckBackName::new(check_back_id)
.map_err(|e| invalid(&context, format!("the check-back-id: {e:?}")))?;
built.with_device(
object_name,
DeviceAccess {
permitted: objects.permitted,
check_back_id,
timeout: Duration::from_secs(timeout_s),
},
)
}
};
}
if table.initiator {
policy = policy.with_initiator_for(table.designation.as_str());
}
tables.insert(built);
}
let mut simulations = Vec::new();
for section in &config.simulations {
let context = format!("simulation of {}/{}", section.domain, section.name);
domain_known(§ion.domain, &context)?;
let name = object(§ion.domain, §ion.name);
let point = config
.points
.iter()
.find(|point| point.domain == section.domain && point.name == section.name)
.ok_or_else(|| invalid(&context, "it is not a point"))?;
if section.period_ms == 0 {
return Err(invalid(&context, "the period is zero"));
}
if !point.writable {
return Err(invalid(
&context,
"the point is not writable, so the simulation cannot change it",
));
}
match (section.kind, point.kind) {
(SimulationKind::Toggle, PointKind::Boolean) => {}
(SimulationKind::Ramp, PointKind::Integer | PointKind::Unsigned | PointKind::Float) => {
if section.step.is_nan() || section.step <= 0.0 {
return Err(invalid(&context, "a ramp needs a step above zero"));
}
if section.min.is_nan() || section.max.is_nan() || section.min >= section.max {
return Err(invalid(&context, "a ramp needs min below max"));
}
}
(SimulationKind::Sine, PointKind::Float) => {
if section.min.is_nan() || section.max.is_nan() || section.min >= section.max {
return Err(invalid(&context, "a sine needs min below max"));
}
}
_ => {
return Err(invalid(
&context,
"the change does not suit the type of the point",
))
}
}
simulations.push(Simulation {
name,
kind: section.kind,
period: Duration::from_millis(section.period_ms),
min: section.min,
max: section.max,
step: section.step,
point: point.kind,
});
}
let server = TaseServerConfig {
mms: ServerConfig {
poll_interval: Duration::from_millis(config.server.poll_ms.max(1)),
max_associations,
..ServerConfig::new(LinkConfig::default())
},
tables,
policy,
transfer_sets,
data_sets,
data_set_capacity: config.server.data_set_capacity,
devices: device_configs,
transfer: Policy {
expected_send_delay: Duration::from_millis(config.server.expected_send_delay_ms),
ack_timeout: Duration::from_secs(config.server.ack_timeout_s),
},
};
let mut information = Vec::new();
for section in &config.information {
let context = format!("information {}", section.info_reference);
if section.period_ms == 0 {
return Err(invalid(&context, "the period is zero"));
}
igs_iec_tase2_link::information::InformationMessage::new(
section.info_reference,
section.local_reference,
1,
section.stream.clone().into_bytes(),
)
.map_err(|e| invalid(&context, format!("{e:?}")))?;
information.push(InformationSource {
info_reference: section.info_reference,
local_reference: section.local_reference,
stream: section.stream.clone().into_bytes(),
period: Duration::from_millis(section.period_ms),
});
}
Ok(Built {
vmd,
server,
simulations,
information,
})
}
pub fn parse(text: &str) -> Result<Config, ConfigError> {
toml::from_str(text).map_err(|e| ConfigError(e.to_string()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
fn text(sections: &str) -> String {
format!("[[domain]]\nname = \"ICC1\"\n{sections}")
}
fn build_text(sections: &str) -> Result<Built, ConfigError> {
let config = parse(&text(sections))?;
build(&config, 64)
}
const POINT: &str = r#"[[point]]
domain = "ICC1"
name = "Flow1"
type = "integer"
bits = 16
value = 5
writable = true
"#;
#[test]
fn an_unknown_key_is_refused_with_its_line() {
let error = parse(
r#"[[point]]
domain = "ICC1"
name = "A"
type = "integer"
value = 1
colour = 2
"#,
)
.unwrap_err();
assert!(error.0.contains("colour"), "{error}");
}
#[test]
fn an_integer_outside_its_bits_is_refused_with_the_point_named() {
let error = build_text(
r#"[[point]]
domain = "ICC1"
name = "Big"
type = "integer"
bits = 8
value = 200
"#,
)
.err()
.unwrap();
assert!(
error.0.contains("ICC1/Big") && error.0.contains("does not fit"),
"{error}"
);
}
#[test]
fn a_negative_unsigned_is_refused() {
let error = build_text(
r#"[[point]]
domain = "ICC1"
name = "U"
type = "unsigned"
value = -1
"#,
)
.err()
.unwrap();
assert!(error.0.contains("does not fit"), "{error}");
}
#[test]
fn an_information_message_source_builds_with_its_references_and_stream() {
let built = build_text(
r#"[[information]]
info_reference = 7
local_reference = 1
stream = "demo"
period_ms = 3000
"#,
)
.unwrap();
assert_eq!(built.information.len(), 1);
assert_eq!(built.information[0].info_reference, 7);
assert_eq!(built.information[0].stream, b"demo".to_vec());
}
#[test]
fn an_information_message_source_with_a_zero_period_is_refused() {
let error = build_text(
r#"[[information]]
info_reference = 7
local_reference = 1
stream = "demo"
period_ms = 0
"#,
)
.err()
.unwrap();
assert!(error.0.contains("the period is zero"), "{error}");
}
#[test]
fn a_device_with_its_check_back_id_and_timeout_builds() {
let built = build_text(
r#"[[device]]
domain = "ICC1"
name = "Breaker12"
class = "sbo"
closing_commands = [2]
[[table]]
designation = "1.3.9999.1"
table_id = "BT-1"
domain = "ICC1"
application_references = ["1.3.9999.1"]
[[table.access]]
kind = "device"
name = "Breaker12"
permissions = ["visibility", "select", "operate", "set-tag"]
check_back_id = 12
timeout_s = 5
"#,
)
.unwrap();
assert_eq!(built.server.devices.len(), 1);
assert_eq!(built.server.devices[0].class, DeviceClass::Sbo);
assert_eq!(built.server.devices[0].closing_commands, vec![2]);
}
#[test]
fn a_device_access_without_its_check_back_id_is_refused() {
let error = build_text(
r#"[[device]]
domain = "ICC1"
name = "Valve9"
class = "non-sbo"
[[table]]
designation = "1.3.9999.1"
table_id = "BT-1"
domain = "ICC1"
application_references = ["1.3.9999.1"]
[[table.access]]
kind = "device"
name = "Valve9"
permissions = ["visibility", "operate"]
timeout_s = 5
"#,
)
.err()
.unwrap();
assert!(error.0.contains("needs its check-back-id"), "{error}");
}
#[test]
fn a_point_in_an_undeclared_domain_is_refused() {
let config = parse(
r#"[[point]]
domain = "Other"
name = "A"
type = "boolean"
value = true
"#,
)
.unwrap();
let error = build(&config, 64).err().unwrap();
assert!(error.0.contains("\"Other\" is not declared"), "{error}");
}
#[test]
fn a_data_set_member_that_is_not_a_point_is_refused() {
let error = build_text(&format!(
"{POINT}[[data-set]]\ndomain = \"ICC1\"\nname = \"DS\"\nmembers = [\"Nope\"]\n"
))
.err()
.unwrap();
assert!(error.0.contains("Nope"), "{error}");
}
#[test]
fn a_table_access_to_an_object_that_is_not_declared_is_refused() {
let error = build_text(&format!(
r#"{POINT}[[table]]
designation = "1.3.9999.1"
table_id = "BT-1"
domain = "ICC1"
application_references = ["1.3.9999.1"]
[[table.access]]
kind = "data-set"
name = "Flow1"
permissions = ["visibility"]
"#
))
.err()
.unwrap();
assert!(error.0.contains("it is not a data set"), "{error}");
}
#[test]
fn an_unknown_permission_is_refused() {
let error = build_text(&format!(
r#"{POINT}[[table]]
designation = "1.3.9999.1"
table_id = "BT-1"
domain = "ICC1"
application_references = []
[[table.access]]
kind = "data-value"
name = "Flow1"
permissions = ["fly"]
"#
))
.err()
.unwrap();
assert!(error.0.contains("\"fly\" is not known"), "{error}");
}
#[test]
fn a_table_identifier_longer_than_32_characters_is_refused() {
let error = build_text(&format!(
"[[table]]\ndesignation = \"1.3.9999.1\"\ntable_id = \"{}\"\ndomain = \"ICC1\"\napplication_references = []\n",
"x".repeat(33)
))
.err()
.unwrap();
assert!(error.0.contains("table identifier"), "{error}");
}
#[test]
fn a_simulation_of_a_read_only_point_is_refused() {
let error = build_text(
r#"[[point]]
domain = "ICC1"
name = "Fixed"
type = "boolean"
value = true
[[simulate]]
domain = "ICC1"
name = "Fixed"
kind = "toggle"
period_ms = 100
"#,
)
.err()
.unwrap();
assert!(error.0.contains("not writable"), "{error}");
}
#[test]
fn a_change_that_does_not_suit_the_type_is_refused() {
let error = build_text(
r#"[[point]]
domain = "ICC1"
name = "Flag"
type = "boolean"
value = true
writable = true
[[simulate]]
domain = "ICC1"
name = "Flag"
kind = "ramp"
period_ms = 100
min = 0
max = 3
step = 1
"#,
)
.err()
.unwrap();
assert!(error.0.contains("does not suit the type"), "{error}");
}
#[test]
fn a_valid_file_builds_the_points_the_tables_and_the_simulations() {
let built = build_text(&format!(
r#"{POINT}[[point]]
domain = "ICC1"
name = "Ratio"
type = "float"
value = 0.5
writable = true
[[data-set]]
domain = "ICC1"
name = "DS1"
members = ["Flow1", "Ratio"]
[[table]]
designation = "1.3.9999.1"
table_id = "BT-1"
domain = "ICC1"
application_references = ["1.3.9999.1"]
[[table.access]]
kind = "data-value"
name = "Flow1"
permissions = ["visibility", "get-data-value"]
interval_ms = 500
[[simulate]]
domain = "ICC1"
name = "Flow1"
kind = "ramp"
period_ms = 1000
min = 0
max = 10
step = 1
"#
))
.unwrap();
assert_eq!(built.server.data_sets.len(), 1);
assert_eq!(built.simulations.len(), 1);
assert_eq!(built.simulations[0].point, PointKind::Integer);
}
}