Files
kaptan/firmware/tools/sitl/src/config/tests.rs
T

357 lines
12 KiB
Rust

use std::fs;
use kaptan_config::{ConfigModel, Generation, ProfileStore};
use kaptan_config_mavlink::{
Authorized, ConfigCommand, ResultKind, ServiceResponse, SnapshotImporter,
};
use kaptan_flight_config::{
CandidateProfile, CborError, MAXIMUM_CANDIDATE_BYTES, SCHEMA_ID as PROFILE_SCHEMA_ID,
decode_candidate, encode_candidate,
};
use kaptan_log_mavlink::DiagnosticPayload;
use kaptan_mavlink::{
encoder::MavlinkEncoder,
message::{Message, kaptan::MavMessage},
};
use super::{CAPABILITIES, FileProfileStore, SitlSnapshotImporter, configuration_service};
// Independently authored current-format CBOR map. It is intentionally not
// produced by `encode_candidate`: this is the GCS/upload compatibility vector
// for schema KAPTANPROFILE-01, with a non-default 1.7 kg mass.
const EXTERNAL_PROFILE_CBOR: &[u8] = &[
0xb8, 0x1a, // definite map, 26 entries
0x00, 0x01, // format key/value
0x19, 0xff, 0xff, 0x50, b'K', b'A', b'P', b'T', b'A', b'N', b'P', b'R', b'O', b'F', b'I', b'L',
b'E', b'-', b'0', b'1', // schema key/value
0x01, 0x1a, 0x00, 0x01, 0x86, 0xa0, // prearm dwell: 100000
0x02, 0x19, 0xc3, 0x50, // IMU interval: 50000
0x03, 0x19, 0xc3, 0x50, // IMU freshness: 50000
0x04, 0x1a, 0x00, 0x02, 0x49, 0xf0, // local freshness: 150000
0x05, 0x1a, 0x00, 0x07, 0xa1, 0x20, // link freshness: 500000
0x06, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x07, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x08, 0xfa, 0x3f, 0x80,
0x00, 0x00, 0x09, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0a, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0b, 0xfa,
0x3f, 0x80, 0x00, 0x00, 0x0c, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0d, 0xfa, 0x3f, 0xd9, 0x99,
0x9a, // mass: 1.7
0x0e, 0xfa, 0x41, 0xa0, 0x00, 0x00, // collective thrust: 20
0x0f, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x10, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x11, 0xfa, 0x3f, 0x80,
0x00, 0x00, 0x12, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x13, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x14, 0xfa,
0x3f, 0x80, 0x00, 0x00, 0x15, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x16, 0xfa, 0x3f, 0x80, 0x00, 0x00,
0x17, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x18, 0x18, 0x06, // maximum log level: INFO
];
fn result(response: ServiceResponse) -> kaptan_config_mavlink::ConfigReply {
match response {
ServiceResponse::Result(reply) => reply,
ServiceResponse::Info(_) | ServiceResponse::Descriptors(_) | ServiceResponse::Value(_) => {
panic!("expected a mutating configuration result")
}
}
}
fn crc32c(bytes: &[u8]) -> u32 {
let mut crc = !0_u32;
for byte in bytes {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = if crc & 1 == 0 {
crc >> 1
} else {
(crc >> 1) ^ 0x82f6_3b78
};
}
}
!crc
}
#[test]
fn host_store_round_trips_a_complete_validated_snapshot() {
let path = std::env::temp_dir().join(format!(
"kaptan-config-store-{}-{}.cbor",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let temporary = path.with_extension("cbor.tmp");
let _ = fs::remove_file(&path);
let _ = fs::remove_file(&temporary);
let mut store = FileProfileStore::new(&path);
assert!(store.load().unwrap().is_none());
assert_eq!(
store.persist(&CandidateProfile::default()).unwrap(),
Generation::new(1)
);
let envelope = fs::read(&path).unwrap();
assert!(matches!(
decode_candidate(&envelope, &CAPABILITIES),
Err(CborError::Malformed)
));
let (_, candidate) = store.load().unwrap().expect("persisted candidate");
assert!(candidate.validate(&CAPABILITIES).is_ok());
assert_eq!(
store.persist(&CandidateProfile::default()).unwrap(),
Generation::new(2)
);
let mut reopened = FileProfileStore::new(&path);
assert_eq!(
reopened.load().unwrap().map(|(generation, _)| generation),
Some(Generation::new(2))
);
let mut fresh = FileProfileStore::new(&path);
assert_eq!(
fresh.persist(&CandidateProfile::default()).unwrap(),
Generation::new(3)
);
fs::remove_file(&path).unwrap();
}
#[test]
fn sitl_snapshot_importer_uses_the_real_schema_and_capabilities() {
let candidate = CandidateProfile::default();
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
encode_candidate(candidate, &CAPABILITIES, &mut writer).unwrap();
let length = writer.position();
let bytes = writer.into_inner();
let imported = SitlSnapshotImporter
.decode(&bytes[..length])
.expect("valid current candidate import");
assert!(imported.validate(&CAPABILITIES).is_ok());
}
#[test]
fn sitl_service_imports_a_real_cbor_candidate_only_into_its_transaction() {
let path = std::env::temp_dir().join(format!(
"kaptan-config-import-{}-{}.cbor",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let _ = fs::remove_file(&path);
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
let begin = result(service.dispatch(Authorized::host(
9,
ConfigCommand::Begin {
request_id: 1,
expected_generation: 0,
target: kaptan_config_mavlink::Target::new(1, 1),
},
)));
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
encode_candidate(CandidateProfile::default(), &CAPABILITIES, &mut writer).unwrap();
let length = writer.position();
assert!(length <= 192);
let bytes = writer.into_inner();
let import_begin = result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportBegin {
request_id: 2,
session_id: begin.session_id,
expected_revision: 0,
length: length as u16,
schema_id: PROFILE_SCHEMA_ID,
crc32c: crc32c(&bytes[..length]),
target: kaptan_config_mavlink::Target::new(1, 1),
},
)));
assert_eq!(import_begin.result, ResultKind::Accepted);
let mut chunk = [0; 192];
chunk[..length].copy_from_slice(&bytes[..length]);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportChunk {
request_id: 3,
session_id: begin.session_id,
transfer_id: import_begin.transfer_id,
offset: 0,
length: length as u8,
data: chunk,
target: kaptan_config_mavlink::Target::new(1, 1),
},
)))
.result,
ResultKind::Accepted
);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportFinish {
request_id: 4,
session_id: begin.session_id,
transfer_id: import_begin.transfer_id,
expected_revision: 0,
target: kaptan_config_mavlink::Target::new(1, 1),
},
)))
.result,
ResultKind::Accepted
);
assert!(
service
.manager()
.transaction_value(kaptan_config::FieldId::new(1))
.is_some()
);
assert!(
service
.manager()
.active_value(kaptan_config::FieldId::new(1))
.is_some()
);
let _ = fs::remove_file(&path);
}
#[test]
fn external_gcs_vector_imports_commits_and_reopens_as_the_boot_profile() {
let path = std::env::temp_dir().join(format!(
"kaptan-config-gcs-vector-{}-{}.cbor",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let _ = fs::remove_file(&path);
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
let target = kaptan_config_mavlink::Target::new(1, 1);
let begin = result(service.dispatch(Authorized::host(
9,
ConfigCommand::Begin {
request_id: 1,
expected_generation: 0,
target,
},
)));
let import = result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportBegin {
request_id: 2,
session_id: begin.session_id,
expected_revision: 0,
length: EXTERNAL_PROFILE_CBOR.len() as u16,
schema_id: PROFILE_SCHEMA_ID,
crc32c: crc32c(EXTERNAL_PROFILE_CBOR),
target,
},
)));
let mut data = [0_u8; 192];
data[..EXTERNAL_PROFILE_CBOR.len()].copy_from_slice(EXTERNAL_PROFILE_CBOR);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportChunk {
request_id: 3,
session_id: begin.session_id,
transfer_id: import.transfer_id,
offset: 0,
length: EXTERNAL_PROFILE_CBOR.len() as u8,
data,
target,
},
)))
.result,
ResultKind::Accepted
);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportFinish {
request_id: 4,
session_id: begin.session_id,
transfer_id: import.transfer_id,
expected_revision: 0,
target,
},
)))
.revision,
1
);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::Commit {
request_id: 5,
session_id: begin.session_id,
expected_generation: 0,
expected_revision: 1,
target,
},
)))
.result,
ResultKind::CommittedRestartRequired
);
drop(service);
let reopened = configuration_service(FileProfileStore::new(&path)).unwrap();
assert_eq!(reopened.active_profile().thrust().mass_kg(), 1.7);
let _ = fs::remove_file(&path);
}
#[test]
fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
let path = std::env::temp_dir().join(format!(
"kaptan-sitl-log-{}-{}.cbor",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let _ = fs::remove_file(&path);
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
log::info!("SITL configuration service ready");
let Some(DiagnosticPayload::Log(payload)) = service.pending_diagnostic_payload() else {
panic!("info facade record must reach KAPTAN_LOG");
};
assert_eq!(payload.level, kaptan_log::Severity::Info as u8);
let message = std::str::from_utf8(&payload.message[..usize::from(payload.message_length)])
.expect("facade text remains UTF-8");
assert!(message.contains("config::tests"));
assert!(message.ends_with("SITL configuration service ready"));
let mut encoder = MavlinkEncoder::new(1, 1);
let frame = encoder.custom(&payload);
assert_eq!(frame.message_id(), 51_020);
assert!(matches!(
MavMessage::parse(
mavlink::MavlinkVersion::V2,
frame.message_id(),
frame.payload(),
),
Ok(MavMessage::KAPTAN_LOG(data))
if data.level == payload.level
&& data.message_length == payload.message_length
&& data.message == payload.message
));
let _ = fs::remove_file(&path);
}
#[test]
fn rejected_config_result_enqueues_a_separate_high_level_error_payload() {
let path = std::env::temp_dir().join(format!(
"kaptan-sitl-config-error-{}-{}.cbor",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let _ = fs::remove_file(&path);
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
let response = service.dispatch(Authorized::host(
9,
ConfigCommand::Begin {
request_id: 1,
expected_generation: 1,
target: kaptan_config_mavlink::Target::new(1, 1),
},
));
let reply = result(response);
assert_eq!(reply.result, ResultKind::Rejected);
service.after_config_result(response);
let Some(DiagnosticPayload::Error(payload)) = service.pending_diagnostic_payload() else {
panic!("rejected configuration result must enqueue KAPTAN_ERROR");
};
let error_path = std::str::from_utf8(&payload.path[..usize::from(payload.path_length)])
.expect("derived error path remains UTF-8");
assert_eq!(
error_path,
"kaptan_config_mavlink::protocol::ProtocolError::GenerationConflict"
);
assert_eq!(payload.additional_information_length, 0);
let _ = fs::remove_file(&path);
}