#[cfg(feature = "ffi")]
pub mod ffi;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::panic::{catch_unwind, AssertUnwindSafe};
pub const BRIDGE_API_VERSION: u32 = 1;
pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
fn command_names() -> Vec<&'static str> {
let mut names = vec![
"meta.capabilities",
"meta.version",
"solve",
"atmosphere.density_altitude",
"card.come_ups",
"card.range_table",
"card.wind",
];
#[cfg(feature = "pdf")]
names.push("card.pdf");
names.extend(["profile.validate", "profile.normalize"]);
#[cfg(feature = "profile-import")]
names.push("profile.import_a7p");
#[cfg(feature = "profile-export")]
names.push("profile.export_a7p");
names.extend([
"reticle.catalog",
"reticle.describe",
"reticle.hold",
"reticle.holds",
"reticle.import",
]);
names.extend([
"true.fit",
"true.wind",
"true.tall_target",
"true.dsf",
"true.plan",
"true.dial_plan",
]);
#[cfg(not(target_arch = "wasm32"))]
names.push("bc5d.info");
names
}
fn compiled_features() -> Vec<&'static str> {
[
("pdf", cfg!(feature = "pdf")),
("profile-import", cfg!(feature = "profile-import")),
("profile-export", cfg!(feature = "profile-export")),
("online", cfg!(feature = "online")),
]
.iter()
.filter(|(_, enabled)| *enabled)
.map(|(name, _)| *name)
.collect()
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BridgeRequest {
api_version: u32,
command: String,
#[serde(default)]
request: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BridgeErrorCode {
InvalidJson,
UnsupportedApiVersion,
UnknownCommand,
InvalidRequest,
ResourceLimit,
CommandFailed,
InternalError,
}
fn success(command: &str, result: Value) -> String {
serialize_envelope(&json!({
"ok": true,
"api_version": BRIDGE_API_VERSION,
"engine_version": ENGINE_VERSION,
"command": command,
"result": result,
}))
}
fn error(code: BridgeErrorCode, message: impl Into<String>, details: Option<Value>) -> String {
let mut error = json!({
"code": code,
"message": message.into(),
});
if let Some(details) = details {
error["details"] = details;
}
serialize_envelope(&json!({
"ok": false,
"api_version": BRIDGE_API_VERSION,
"engine_version": ENGINE_VERSION,
"error": error,
}))
}
fn serialize_envelope(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| {
format!(
r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"engine_version":"{ENGINE_VERSION}","error":{{"code":"internal_error","message":"bridge response serialization failed"}}}}"#
)
})
}
pub fn bridge_call(request_json: &str) -> String {
let guarded = catch_unwind(AssertUnwindSafe(|| dispatch(request_json)));
guarded.unwrap_or_else(|_| {
error(
BridgeErrorCode::InternalError,
"bridge command failed unexpectedly",
None,
)
})
}
fn dispatch(request_json: &str) -> String {
if request_json.len() > MAX_REQUEST_BYTES {
return error(
BridgeErrorCode::ResourceLimit,
format!("bridge request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
None,
);
}
let request: BridgeRequest = match serde_json::from_str(request_json) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidJson,
format!("bridge request is not a valid envelope: {err}"),
None,
)
}
};
if request.api_version != BRIDGE_API_VERSION {
return error(
BridgeErrorCode::UnsupportedApiVersion,
format!(
"unsupported api_version {}; this build speaks {BRIDGE_API_VERSION}",
request.api_version
),
None,
);
}
match request.command.as_str() {
"meta.capabilities" => success(
"meta.capabilities",
json!({
"engine_version": ENGINE_VERSION,
"bridge_api_version": BRIDGE_API_VERSION,
"commands": command_names(),
"features": compiled_features(),
"solve_schema_version": crate::solve_json::SOLVE_JSON_SCHEMA_VERSION_V1,
}),
),
"meta.version" => success("meta.version", json!({ "engine_version": ENGINE_VERSION })),
"solve" => run_solve(&request.request),
"atmosphere.density_altitude" => run_atmosphere_density_altitude(&request.request),
"card.come_ups" => run_service(
&request.request,
"card.come_ups",
crate::card_service::come_ups_v1,
),
"card.range_table" => run_service(
&request.request,
"card.range_table",
crate::card_service::range_table_v1,
),
"card.wind" => run_service(
&request.request,
"card.wind",
crate::card_service::wind_card_v1,
),
#[cfg(feature = "pdf")]
"card.pdf" => run_card_pdf(&request.request),
"profile.validate" => run_profile_validate(&request.request),
"profile.normalize" => run_profile_normalize(&request.request),
#[cfg(feature = "profile-import")]
"profile.import_a7p" => run_profile_import_a7p(&request.request),
#[cfg(feature = "profile-export")]
"profile.export_a7p" => run_profile_export_a7p(&request.request),
"reticle.catalog" => run_reticle_catalog(),
"reticle.describe" => run_reticle_describe(&request.request),
"reticle.hold" => run_reticle_hold(&request.request),
"reticle.holds" => run_reticle_holds(&request.request),
"reticle.import" => run_reticle_import(&request.request),
"true.fit" => run_service(
&request.request,
"true.fit",
crate::truing_uncertainty::run_uncertainty_truing_v1,
),
"true.wind" => run_service(
&request.request,
"true.wind",
crate::truing_wind::solve_wind_truing,
),
"true.tall_target" => run_service(
&request.request,
"true.tall_target",
crate::truing_service::tall_target_v1,
),
"true.dsf" => run_service_detailed(
&request.request,
"true.dsf",
crate::truing_service::derive_dsf_point_v1,
crate::truing_service::DsfServiceErrorV1::failure_details,
),
"true.plan" => run_service_detailed(
&request.request,
"true.plan",
crate::truing_plan::plan_truing_experiment_v1,
crate::truing_plan::TruingPlanErrorV1::failure_details,
),
"true.dial_plan" => run_service_detailed(
&request.request,
"true.dial_plan",
crate::truing_service::dial_plan_v1,
crate::optic::OpticError::failure_details,
),
#[cfg(not(target_arch = "wasm32"))]
"bc5d.info" => run_bc5d_info(&request.request),
other => error(
BridgeErrorCode::UnknownCommand,
format!(
"unknown command '{other}'; this build supports: {}",
command_names().join(", ")
),
None,
),
}
}
fn run_solve(inner: &Value) -> String {
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
"'solve' requires a request payload (solve-json v1 document)",
None,
);
}
let inner_text = match serde_json::to_string(inner) {
Ok(text) => text,
Err(err) => {
return error(
BridgeErrorCode::InternalError,
format!("failed to re-serialize solve request: {err}"),
None,
)
}
};
let request = match crate::solve_json::decode_solve_request_v1(&inner_text) {
Ok(request) => request,
Err(envelope) => return command_error("solve request rejected", &envelope),
};
match crate::solve_v1(request) {
Ok(successful) => match serde_json::to_value(&successful) {
Ok(result) => success("solve", result),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize solve result: {err}"),
None,
),
},
Err(envelope) => command_error("solve failed", &envelope),
}
}
fn run_atmosphere_density_altitude(inner: &Value) -> String {
const COMMAND: &str = "atmosphere.density_altitude";
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
"'atmosphere.density_altitude' requires a request payload ({\"atmosphere\": {...}})",
None,
);
}
let outcome = crate::atmosphere_service::decode_density_altitude_request_v1(inner)
.and_then(|request| crate::atmosphere_service::density_altitude_v1(&request));
match outcome {
Ok(response) => match serde_json::to_value(&response) {
Ok(result) => success(COMMAND, result),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize {COMMAND} result: {err}"),
None,
),
},
Err(envelope) => command_error("atmosphere.density_altitude request rejected", &envelope),
}
}
fn run_service<Req, Resp, E, F>(inner: &Value, command: &'static str, service: F) -> String
where
Req: serde::de::DeserializeOwned,
Resp: serde::Serialize,
E: std::fmt::Display,
F: FnOnce(&Req) -> Result<Resp, E>,
{
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
format!("'{command}' requires a request payload"),
None,
);
}
let request: Req = match serde_json::from_value(inner.clone()) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("{command} request rejected: {err}"),
None,
)
}
};
match service(&request) {
Ok(response) => match serde_json::to_value(&response) {
Ok(result) => success(command, result),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize {command} result: {err}"),
None,
),
},
Err(err) => error(
BridgeErrorCode::CommandFailed,
format!("{command} failed: {err}"),
None,
),
}
}
fn run_service_detailed<Req, Resp, E, F, D>(
inner: &Value,
command: &'static str,
service: F,
details: D,
) -> String
where
Req: serde::de::DeserializeOwned,
Resp: serde::Serialize,
E: std::fmt::Display,
F: FnOnce(&Req) -> Result<Resp, E>,
D: FnOnce(&E) -> Option<Value>,
{
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
format!("'{command}' requires a request payload"),
None,
);
}
let request: Req = match serde_json::from_value(inner.clone()) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("{command} request rejected: {err}"),
None,
)
}
};
match service(&request) {
Ok(response) => match serde_json::to_value(&response) {
Ok(result) => success(command, result),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize {command} result: {err}"),
None,
),
},
Err(err) => {
let d = details(&err);
error(
BridgeErrorCode::CommandFailed,
format!("{command} failed: {err}"),
d,
)
}
}
}
#[cfg(feature = "pdf")]
pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
#[cfg(feature = "pdf")]
fn pdf_over_cap_error(byte_length: usize, row_count: usize, page_count: usize) -> Option<String> {
(byte_length > MAX_PDF_BYTES).then(|| {
error(
BridgeErrorCode::ResourceLimit,
format!(
"generated dope card is {byte_length} bytes; the limit is {MAX_PDF_BYTES} \
({row_count} rows, {page_count} pages)"
),
None,
)
})
}
#[cfg(feature = "pdf")]
const STORED_CARD_KEY: &str = "stored_card";
#[cfg(feature = "pdf")]
fn run_card_pdf(inner: &Value) -> String {
use crate::card_service::CardServiceError;
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
"'card.pdf' requires a request payload (card v1 document)",
None,
);
}
let mut payload = inner.clone();
let stored_value = payload
.as_object_mut()
.and_then(|object| object.remove(STORED_CARD_KEY))
.filter(|value| !value.is_null());
let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("card.pdf request rejected: {err}"),
None,
)
}
};
let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
Some(value) => match serde_json::from_value(value) {
Ok(stored) => Some(stored),
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
None,
)
}
},
None => None,
};
let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
Ok(card) => card,
Err(err @ CardServiceError::TooLarge(_)) => {
return error(
BridgeErrorCode::ResourceLimit,
format!("card.pdf refused: {err}"),
None,
)
}
Err(err) => {
return error(
BridgeErrorCode::CommandFailed,
format!("card.pdf failed: {err}"),
None,
)
}
};
let byte_length = card.pdf_bytes.len();
if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
return envelope;
}
let mut response = json!({
"pdf_base64": encode_base64(&card.pdf_bytes),
"byte_length": byte_length,
"page_count": card.page_count,
"row_count": card.row_count,
"kind": crate::card_service::PDF_CARD_KIND,
"source": card.source.as_str(),
"unprintable_title_chars": card.unprintable_title_chars,
});
if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
object.insert(
"truncation".to_string(),
json!({
"requested_end": truncation.requested_end,
"last_row": truncation.last_row,
"reach": truncation.reach,
}),
);
}
success("card.pdf", response)
}
#[cfg(any(feature = "pdf", feature = "profile-export"))]
fn encode_base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let triple = (u32::from(chunk[0]) << 16)
| (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
| u32::from(chunk.get(2).copied().unwrap_or(0));
out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
out.push(if chunk.len() > 1 {
char::from(ALPHABET[(triple >> 6) as usize & 63])
} else {
'='
});
out.push(if chunk.len() > 2 {
char::from(ALPHABET[triple as usize & 63])
} else {
'='
});
}
out
}
fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
let details = serde_json::to_value(typed).ok();
error(BridgeErrorCode::CommandFailed, message, details)
}
fn decode_profile_document(
inner: &Value,
command: &'static str,
) -> Result<crate::profile::ProfileData, String> {
if inner.is_null() {
return Err(error(
BridgeErrorCode::InvalidRequest,
format!("'{command}' requires a request payload (a ProfileData JSON document)"),
None,
));
}
serde_json::from_value(inner.clone()).map_err(|err| {
error(
BridgeErrorCode::InvalidRequest,
format!("{command} request is not a ProfileData document: {err}"),
None,
)
})
}
fn run_profile_validate(inner: &Value) -> String {
let profile = match decode_profile_document(inner, "profile.validate") {
Ok(profile) => profile,
Err(envelope) => return envelope,
};
let warnings = profile.validation_warnings();
match serde_json::to_value(&profile) {
Ok(normalized) => success(
"profile.validate",
json!({
"valid": warnings.is_empty(),
"warnings": warnings,
"normalized": normalized,
}),
),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize normalized profile: {err}"),
None,
),
}
}
fn run_profile_normalize(inner: &Value) -> String {
let profile = match decode_profile_document(inner, "profile.normalize") {
Ok(profile) => profile,
Err(envelope) => return envelope,
};
match serde_json::to_value(&profile) {
Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize normalized profile: {err}"),
None,
),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Bc5dInfoRequest {
path: String,
}
#[cfg(not(target_arch = "wasm32"))]
fn run_bc5d_info(inner: &Value) -> String {
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
"'bc5d.info' requires a request payload ({\"path\": ...})",
None,
);
}
let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("bc5d.info request rejected: {err}"),
None,
)
}
};
let table =
match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(&request.path)) {
Ok(table) => table,
Err(err) => {
return error(
BridgeErrorCode::CommandFailed,
format!("bc5d.info: not a usable BC5D table: {err}"),
None,
)
}
};
let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
let (weight_lo, weight_hi) = table.weight_range();
let (vel_lo, vel_hi) = table.velocity_range();
success(
"bc5d.info",
json!({
"valid": true,
"crc_ok": true,
"format_version": table.version(),
"caliber": table.caliber(),
"caliber_key": table.caliber_key(),
"api_version": table.api_version(),
"generated_timestamp": table.timestamp(),
"bins": {
"weight": weight,
"bc": bc,
"muzzle_velocity": muzzle_vel,
"current_velocity": current_vel,
"drag_types": drag_types,
},
"total_cells": table.total_cells(),
"weight_range_grains": [weight_lo, weight_hi],
"velocity_range_fps": [vel_lo, vel_hi],
}),
)
}
#[cfg(feature = "profile-import")]
pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
#[cfg(feature = "profile-import")]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileImportA7pRequest {
a7p_base64: String,
#[serde(default)]
zero_click: Option<String>,
#[serde(default)]
strict: bool,
}
#[cfg(feature = "profile-import")]
fn run_profile_import_a7p(inner: &Value) -> String {
use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
"'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
None,
);
}
let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("profile.import_a7p request rejected: {err}"),
None,
)
}
};
let zero_click = match request.zero_click.as_deref() {
Some(raw) => match crate::adjustment::parse_click_value(raw) {
Ok(click) => Some(click),
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("profile.import_a7p zero_click: {err}"),
None,
)
}
},
None => None,
};
let bytes = match decode_base64(&request.a7p_base64) {
Ok(bytes) => bytes,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("profile.import_a7p a7p_base64: {err}"),
None,
)
}
};
if bytes.len() > MAX_A7P_DECODED_BYTES {
return error(
BridgeErrorCode::ResourceLimit,
format!(
"decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
bytes.len()
),
None,
);
}
let doc = match parse_a7p(&bytes) {
Ok(doc) => doc,
Err(err) => {
return error(
BridgeErrorCode::CommandFailed,
format!("not a usable .a7p file: {err}"),
None,
)
}
};
if request.strict {
if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
return error(
BridgeErrorCode::CommandFailed,
format!(
"checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
),
None,
);
}
}
let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
Ok(outcome) => outcome,
Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
};
let unknown_fields: Vec<Value> = doc
.unknown_fields
.iter()
.map(|u| json!({ "context": u.context, "number": u.number }))
.collect();
match serde_json::to_value(&outcome.profile) {
Ok(profile) => success(
"profile.import_a7p",
json!({
"profile": profile,
"warnings": outcome.report.warnings,
"mapped": outcome.report.mapped,
"unmapped": outcome.report.unmapped,
"unknown_fields": unknown_fields,
}),
),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize imported profile: {err}"),
None,
),
}
}
#[cfg(feature = "profile-export")]
fn run_profile_export_a7p(inner: &Value) -> String {
use crate::profile_export::{export_a7p, A7pExportError, CARRIED_FIELDS};
let profile = match decode_profile_document(inner, "profile.export_a7p") {
Ok(profile) => profile,
Err(envelope) => return envelope,
};
let export = match export_a7p(&profile) {
Ok(export) => export,
Err(err) => {
let details = match &err {
A7pExportError::Units(_) => json!({ "reason": "units" }),
A7pExportError::DragModel(model) => {
json!({ "reason": "drag_model", "drag_model": model })
}
A7pExportError::Field { field, .. } => {
json!({ "reason": "field", "field": field })
}
};
return error(
BridgeErrorCode::CommandFailed,
err.to_string(),
Some(details),
);
}
};
let not_carried = match serde_json::to_value(&export.not_carried) {
Ok(value) => value,
Err(err) => {
return error(
BridgeErrorCode::InternalError,
format!("failed to serialize the not-carried list: {err}"),
None,
)
}
};
success(
"profile.export_a7p",
json!({
"a7p_base64": encode_base64(&export.bytes),
"byte_length": export.bytes.len(),
"carried_fields": CARRIED_FIELDS,
"not_carried": not_carried,
"dropped_fields": export.dropped_fields(),
"warnings": export.warnings,
}),
)
}
#[cfg(feature = "profile-import")]
fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
fn sextet(c: u8) -> Result<u32, String> {
match c {
b'A'..=b'Z' => Ok(u32::from(c - b'A')),
b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
b'+' => Ok(62),
b'/' => Ok(63),
_ => Err(format!("invalid base64 character {:?}", char::from(c))),
}
}
let bytes = input.as_bytes();
let data = match bytes {
[rest @ .., b'=', b'='] => rest,
[rest @ .., b'='] => rest,
_ => bytes,
};
if data.contains(&b'=') {
return Err("'=' is only valid as trailing padding".to_string());
}
if data.len() % 4 == 1 {
return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
}
let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
let mut acc: u32 = 0;
let mut bits: u32 = 0;
for &c in data {
acc = (acc << 6) | sextet(c)?;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum ReticleGenerator {
MilGrid { spacing_mil: f64, extent_mil: f64 },
Tree {
rows: usize,
row_spacing_mil: f64,
spread_step_mil: f64,
},
Bdc { drops: Vec<(f64, f64)> },
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReticlePlaneOverride {
#[serde(default)]
focal_plane: Option<crate::reticle::FocalPlane>,
#[serde(default)]
reference_magnification: Option<f64>,
}
fn resolve_reticle(
command: &'static str,
reticle: Option<crate::reticle::ReticleDescription>,
generator: Option<ReticleGenerator>,
catalog: Option<String>,
plane: &ReticlePlaneOverride,
) -> Result<crate::reticle::ReticleDescription, String> {
use crate::reticle::ReticleDescription;
let named = [reticle.is_some(), generator.is_some(), catalog.is_some()]
.iter()
.filter(|named| **named)
.count();
if named > 1 {
return Err(error(
BridgeErrorCode::InvalidRequest,
format!(
"{command} takes exactly one of 'reticle', 'generator' or 'catalog' — \
supplying more than one is a caller bug rather than a preference"
),
None,
));
}
let reticle = match (reticle, catalog) {
(reticle, None) => reticle,
(_, Some(id)) => match crate::reticle_catalog::by_id(&id) {
Some(Ok(built)) => Some(built),
Some(Err(err)) => return Err(reticle_error_envelope(command, &err)),
None => {
return Err(error(
BridgeErrorCode::InvalidRequest,
format!(
"{command}: no catalog reticle with id {id:?}; \
'reticle.catalog' lists what this build has"
),
None,
))
}
},
};
let mut described = match (reticle, generator) {
(Some(_), Some(_)) => unreachable!("the count above rejected more than one source"),
(None, None) => {
return Err(error(
BridgeErrorCode::InvalidRequest,
format!(
"{command} requires one of 'reticle' (a full description), \
'generator' or 'catalog'"
),
None,
))
}
(Some(reticle), None) => {
if plane.focal_plane.is_some() || plane.reference_magnification.is_some() {
return Err(error(
BridgeErrorCode::InvalidRequest,
format!(
"{command}: 'focal_plane' and 'reference_magnification' override a \
GENERATED reticle; a 'reticle' or 'catalog' one already carries its own"
),
None,
));
}
reticle
}
(None, Some(generator)) => {
let built = match generator {
ReticleGenerator::MilGrid {
spacing_mil,
extent_mil,
} => ReticleDescription::mil_grid(spacing_mil, extent_mil),
ReticleGenerator::Tree {
rows,
row_spacing_mil,
spread_step_mil,
} => ReticleDescription::tree(rows, row_spacing_mil, spread_step_mil),
ReticleGenerator::Bdc { drops } => ReticleDescription::bdc_from_drops(&drops),
};
match built {
Ok(built) => built,
Err(err) => return Err(reticle_error_envelope(command, &err)),
}
}
};
if let Some(focal_plane) = plane.focal_plane {
described.focal_plane = focal_plane;
}
if let Some(reference_magnification) = plane.reference_magnification {
described.reference_magnification = reference_magnification;
}
Ok(described)
}
fn reticle_error_envelope(command: &'static str, err: &crate::reticle::ReticleError) -> String {
use crate::reticle::ReticleError;
let mut details = json!({ "reason": match err {
ReticleError::NonPositiveMagnification { .. } => "non_positive_magnification",
ReticleError::NonPositiveReferenceMagnification { .. } => {
"non_positive_reference_magnification"
}
ReticleError::NoMarks => "no_marks",
ReticleError::TooManyMarks { .. } => "too_many_marks",
ReticleError::NonFiniteMark { .. } => "non_finite_mark",
ReticleError::NonFiniteHold { .. } => "non_finite_hold",
ReticleError::InvalidGeneratorParameter { .. } => "invalid_generator_parameter",
ReticleError::InvalidSpec(_) => "invalid_spec",
}});
match err {
ReticleError::NonPositiveMagnification { magnification } => {
details["magnification"] = json!(magnification);
}
ReticleError::NonPositiveReferenceMagnification {
reference_magnification,
} => {
details["reference_magnification"] = json!(reference_magnification);
}
ReticleError::TooManyMarks { count, max } => {
details["count"] = json!(count);
details["max"] = json!(max);
}
ReticleError::NonFiniteMark { index } => {
details["index"] = json!(index);
}
ReticleError::InvalidGeneratorParameter {
parameter,
value,
rule,
} => {
details["parameter"] = json!(parameter);
details["value"] = json!(value);
details["rule"] = json!(rule);
}
ReticleError::NoMarks
| ReticleError::NonFiniteHold { .. }
| ReticleError::InvalidSpec(_) => {}
}
details["command"] = json!(command);
error(
BridgeErrorCode::CommandFailed,
err.to_string(),
Some(details),
)
}
fn nearest_mark_value(
reticle: &crate::reticle::ReticleDescription,
hold: &crate::reticle::ReticleHold,
) -> Value {
match hold
.nearest_mark
.and_then(|index| reticle.marks.get(index).map(|mark| (index, mark)))
{
Some((index, mark)) => json!({
"index": index,
"kind": mark.kind.as_str(),
"label": mark.label,
"nominal": { "down_mil": mark.down_mil, "right_mil": mark.right_mil },
"true_angular": {
"down_mil": mark.down_mil * hold.mark_scale,
"right_mil": mark.right_mil * hold.mark_scale,
},
}),
None => Value::Null,
}
}
fn run_reticle_catalog() -> String {
let reticles: Vec<Value> = crate::reticle_catalog::catalog()
.into_iter()
.map(|entry| {
let mark_count = crate::reticle_catalog::by_id(entry.id)
.and_then(|built| built.ok())
.map(|reticle| reticle.marks.len());
json!({
"id": entry.id,
"display_name": entry.display_name,
"source": entry.source,
"notes": entry.notes,
"mark_count": mark_count,
})
})
.collect();
success("reticle.catalog", json!({ "reticles": reticles }))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReticleDescribeRequest {
#[serde(default)]
reticle: Option<crate::reticle::ReticleDescription>,
#[serde(default)]
generator: Option<ReticleGenerator>,
#[serde(default)]
catalog: Option<String>,
#[serde(default)]
focal_plane: Option<crate::reticle::FocalPlane>,
#[serde(default)]
reference_magnification: Option<f64>,
#[serde(default)]
magnification: Option<f64>,
}
fn run_reticle_describe(inner: &Value) -> String {
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
"'reticle.describe' requires a request payload ({\"reticle\": ...} or {\"generator\": ...})",
None,
);
}
let request: ReticleDescribeRequest = match serde_json::from_value(inner.clone()) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("reticle.describe request rejected: {err}"),
None,
)
}
};
let plane = ReticlePlaneOverride {
focal_plane: request.focal_plane,
reference_magnification: request.reference_magnification,
};
let reticle = match resolve_reticle(
"reticle.describe",
request.reticle,
request.generator,
request.catalog,
&plane,
) {
Ok(reticle) => reticle,
Err(envelope) => return envelope,
};
if let Err(err) = reticle.validate() {
return reticle_error_envelope("reticle.describe", &err);
}
let scaled = match request.magnification {
Some(magnification) => match reticle.scaled_marks(magnification) {
Ok(marks) => json!({
"magnification": magnification,
"mark_scale": reticle.mark_scale(magnification),
"marks": marks
.iter()
.map(|mark| json!({
"down_mil": mark.down_mil,
"right_mil": mark.right_mil,
}))
.collect::<Vec<_>>(),
}),
Err(err) => return reticle_error_envelope("reticle.describe", &err),
},
None => Value::Null,
};
match serde_json::to_value(&reticle) {
Ok(described) => success(
"reticle.describe",
json!({
"reticle": described,
"focal_plane": reticle.focal_plane.label(),
"magnification_dependent": reticle.focal_plane.is_magnification_dependent(),
"mark_count": reticle.marks.len(),
"scaled": scaled,
}),
),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize reticle description: {err}"),
None,
),
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReticleHoldRequest {
#[serde(default)]
reticle: Option<crate::reticle::ReticleDescription>,
#[serde(default)]
generator: Option<ReticleGenerator>,
#[serde(default)]
catalog: Option<String>,
#[serde(default)]
focal_plane: Option<crate::reticle::FocalPlane>,
#[serde(default)]
reference_magnification: Option<f64>,
drop_mil: f64,
#[serde(default)]
wind_mil: f64,
magnification: f64,
}
fn run_reticle_hold(inner: &Value) -> String {
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
"'reticle.hold' requires a request payload ({reticle|generator, drop_mil, magnification})",
None,
);
}
let request: ReticleHoldRequest = match serde_json::from_value(inner.clone()) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("reticle.hold request rejected: {err}"),
None,
)
}
};
let plane = ReticlePlaneOverride {
focal_plane: request.focal_plane,
reference_magnification: request.reference_magnification,
};
let reticle = match resolve_reticle(
"reticle.hold",
request.reticle,
request.generator,
request.catalog,
&plane,
) {
Ok(reticle) => reticle,
Err(envelope) => return envelope,
};
let hold = match crate::reticle::hold_point_in_reticle(
request.drop_mil,
request.wind_mil,
request.magnification,
&reticle,
) {
Ok(hold) => hold,
Err(err) => return reticle_error_envelope("reticle.hold", &err),
};
let nearest = nearest_mark_value(&reticle, &hold);
match serde_json::to_value(&hold) {
Ok(hold_value) => success(
"reticle.hold",
json!({
"hold": hold_value,
"nearest_mark": nearest,
}),
),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize reticle hold: {err}"),
None,
),
}
}
pub const MAX_RETICLE_HOLDS: usize = 4096;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReticleHoldsEntry {
drop_mil: f64,
#[serde(default)]
wind_mil: f64,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReticleHoldsRequest {
#[serde(default)]
reticle: Option<crate::reticle::ReticleDescription>,
#[serde(default)]
generator: Option<ReticleGenerator>,
#[serde(default)]
catalog: Option<String>,
#[serde(default)]
focal_plane: Option<crate::reticle::FocalPlane>,
#[serde(default)]
reference_magnification: Option<f64>,
magnification: f64,
holds: Vec<ReticleHoldsEntry>,
}
fn run_reticle_holds(inner: &Value) -> String {
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
"'reticle.holds' requires a request payload ({reticle|generator|catalog, magnification, holds})",
None,
);
}
let request: ReticleHoldsRequest = match serde_json::from_value(inner.clone()) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("reticle.holds request rejected: {err}"),
None,
)
}
};
if request.holds.len() > MAX_RETICLE_HOLDS {
return error(
BridgeErrorCode::ResourceLimit,
format!(
"reticle.holds was given {} holds; the limit is {MAX_RETICLE_HOLDS}",
request.holds.len()
),
None,
);
}
let plane = ReticlePlaneOverride {
focal_plane: request.focal_plane,
reference_magnification: request.reference_magnification,
};
let reticle = match resolve_reticle(
"reticle.holds",
request.reticle,
request.generator,
request.catalog,
&plane,
) {
Ok(reticle) => reticle,
Err(envelope) => return envelope,
};
let mut placed = Vec::with_capacity(request.holds.len());
let mut scale = 1.0;
for entry in &request.holds {
let hold = match crate::reticle::hold_point_in_reticle(
entry.drop_mil,
entry.wind_mil,
request.magnification,
&reticle,
) {
Ok(hold) => hold,
Err(err) => return reticle_error_envelope("reticle.holds", &err),
};
scale = hold.mark_scale;
let nearest = nearest_mark_value(&reticle, &hold);
match serde_json::to_value(&hold) {
Ok(mut value) => {
if let Some(object) = value.as_object_mut() {
object.remove("mark_scale");
}
placed.push(json!({ "hold": value, "nearest_mark": nearest }));
}
Err(err) => {
return error(
BridgeErrorCode::InternalError,
format!("failed to serialize reticle hold: {err}"),
None,
)
}
}
}
success(
"reticle.holds",
json!({ "mark_scale": scale, "holds": placed }),
)
}
pub const MAX_RETICLE_DOCUMENT_BYTES: usize = 512 * 1024;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ReticleDocumentFormat {
Ventum,
ReticleXml,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReticleImportRequest {
format: ReticleDocumentFormat,
document: String,
}
fn run_reticle_import(inner: &Value) -> String {
if inner.is_null() {
return error(
BridgeErrorCode::InvalidRequest,
"'reticle.import' requires a request payload ({\"format\": ..., \"document\": ...})",
None,
);
}
let request: ReticleImportRequest = match serde_json::from_value(inner.clone()) {
Ok(request) => request,
Err(err) => {
return error(
BridgeErrorCode::InvalidRequest,
format!("reticle.import request rejected: {err}"),
None,
)
}
};
if request.document.len() > MAX_RETICLE_DOCUMENT_BYTES {
return error(
BridgeErrorCode::ResourceLimit,
format!(
"reticle document is {} bytes; the limit is {MAX_RETICLE_DOCUMENT_BYTES}",
request.document.len()
),
None,
);
}
let (reticle, report, format) = match request.format {
ReticleDocumentFormat::Ventum => {
match crate::reticle_import::import_ventum_reticle_with_report(&request.document) {
Ok((reticle, report)) => (
reticle,
json!({
"dropped_elements": report.dropped_elements,
"dropped_element_types": report
.dropped_element_types
.iter()
.map(|(tag, count)| json!({ "tag": tag, "count": count }))
.collect::<Vec<_>>(),
"arcs": report
.arcs
.iter()
.map(|arc| json!({
"center": { "down_mil": arc.center.down_mil, "right_mil": arc.center.right_mil },
"radius_mil": arc.radius_mil,
"start_degrees": arc.start_degrees,
"end_degrees": arc.end_degrees,
"sweep_degrees": arc.sweep_degrees,
"start_tip": { "down_mil": arc.start_tip.down_mil, "right_mil": arc.start_tip.right_mil },
"apex": { "down_mil": arc.apex.down_mil, "right_mil": arc.apex.right_mil },
"end_tip": { "down_mil": arc.end_tip.down_mil, "right_mil": arc.end_tip.right_mil },
}))
.collect::<Vec<_>>(),
"arcs_unresolved": report.arcs_unresolved,
"circle_repeats_unreadable": report.circle_repeats_unreadable,
}),
"ventum",
),
Err(err) => return reticle_error_envelope("reticle.import", &err),
}
}
ReticleDocumentFormat::ReticleXml => {
match crate::reticle_document_import::import_reticle_document_with_report(
&request.document,
) {
Ok((reticle, report)) => (
reticle,
json!({
"drawing_elements_dropped": report.drawing_elements_dropped,
"dropped_element_tags": report
.dropped_element_tags
.iter()
.map(|(tag, count)| json!({ "tag": tag, "count": count }))
.collect::<Vec<_>>(),
"canvas_mil": report
.canvas_mil
.map(|(x, y)| json!({ "size_x_mil": x, "size_y_mil": y })),
"zero_in_canvas_mil": report
.zero_in_canvas_mil
.map(|(x, y)| json!({ "zero_x_mil": x, "zero_y_mil": y })),
}),
"reticle_xml",
),
Err(err) => return reticle_error_envelope("reticle.import", &err),
}
}
};
match serde_json::to_value(&reticle) {
Ok(imported) => success(
"reticle.import",
json!({
"reticle": imported,
"format": format,
"mark_count": reticle.marks.len(),
"report": report,
}),
),
Err(err) => error(
BridgeErrorCode::InternalError,
format!("failed to serialize imported reticle: {err}"),
None,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn call(value: Value) -> Value {
let raw = bridge_call(&value.to_string());
serde_json::from_str(&raw).expect("bridge output must be valid JSON")
}
#[test]
fn capabilities_reports_commands_and_versions() {
let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
assert_eq!(out["ok"], true);
assert_eq!(out["api_version"], 1);
assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
let commands: Vec<String> =
serde_json::from_value(out["result"]["commands"].clone()).unwrap();
assert!(commands.contains(&"solve".to_string()));
assert!(commands.contains(&"meta.capabilities".to_string()));
}
#[test]
fn density_altitude_is_listed_right_after_solve() {
let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
let commands: Vec<String> =
serde_json::from_value(out["result"]["commands"].clone()).unwrap();
let solve = commands.iter().position(|c| c == "solve").unwrap();
assert_eq!(
commands.get(solve + 1).map(String::as_str),
Some("atmosphere.density_altitude"),
"{commands:?}"
);
}
#[test]
fn density_altitude_reports_both_definitions_at_icao_standard_humid_air() {
let out = call(json!({
"api_version": 1,
"command": "atmosphere.density_altitude",
"request": {"atmosphere": {
"altitude_m": 0.0,
"temperature_k": 288.15,
"pressure_pa": 101325.0,
"relative_humidity": 0.5,
}},
}));
assert_eq!(out["ok"], true, "{out}");
assert_eq!(out["command"], "atmosphere.density_altitude");
let result = &out["result"];
let faa = result["density_altitude"]["faa_rule"]["m"]
.as_f64()
.unwrap();
let matched = result["density_altitude"]["density_matched"]["m"]
.as_f64()
.unwrap();
assert!(faa.abs() < 1e-9, "{result}");
assert!((matched - 33.10).abs() < 0.2, "{result}");
let ft = result["density_altitude"]["density_matched"]["ft"]
.as_f64()
.unwrap();
assert!((ft * 0.3048 - matched).abs() < 1e-9, "{result}");
assert!(result["air_density_kg_m3"].as_f64().unwrap() > 1.2);
assert_eq!(result["assumptions"], json!([]), "{result}");
assert_eq!(result["atmosphere"]["pressure_pa"], 101325.0);
}
#[test]
fn density_altitude_empty_atmosphere_resolves_to_announced_icao_defaults() {
let out = call(json!({
"api_version": 1,
"command": "atmosphere.density_altitude",
"request": {"atmosphere": {}},
}));
assert_eq!(out["ok"], true, "{out}");
let assumptions = out["result"]["assumptions"].as_array().unwrap();
assert!(!assumptions.is_empty(), "{out}");
for notice in assumptions {
assert!(
notice["path"]
.as_str()
.unwrap_or_default()
.starts_with("$.atmosphere."),
"{notice}"
);
}
}
#[test]
fn density_altitude_without_payload_is_invalid_request() {
let out = call(json!({"api_version": 1, "command": "atmosphere.density_altitude"}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "invalid_request");
}
#[test]
fn density_altitude_rejects_a_unit_suffixed_field_at_its_path() {
let out = call(json!({
"api_version": 1,
"command": "atmosphere.density_altitude",
"request": {"atmosphere": {"temperature_c": 30.0}},
}));
assert_eq!(out["ok"], false, "{out}");
assert_eq!(out["error"]["code"], "command_failed");
assert_eq!(out["error"]["details"]["error"]["code"], "unknown_field");
assert_eq!(
out["error"]["details"]["error"]["path"],
"$.atmosphere.temperature_c"
);
}
#[test]
fn density_altitude_refuses_celsius_sent_as_kelvin() {
let out = call(json!({
"api_version": 1,
"command": "atmosphere.density_altitude",
"request": {"atmosphere": {"temperature_k": 15.0}},
}));
assert_eq!(out["ok"], false, "{out}");
assert_eq!(out["error"]["code"], "command_failed");
assert_eq!(
out["error"]["details"]["error"]["path"],
"$.atmosphere.temperature_k"
);
}
#[test]
fn density_altitude_humidity_as_percent_is_located_in_the_solve_envelope() {
let out = call(json!({
"api_version": 1,
"command": "atmosphere.density_altitude",
"request": {"atmosphere": {"relative_humidity": 50.0}},
}));
assert_eq!(out["ok"], false, "{out}");
assert_eq!(out["error"]["code"], "command_failed");
assert!(
out["error"]["details"]
.to_string()
.contains("$.atmosphere.relative_humidity"),
"{out}"
);
}
#[test]
fn invalid_json_is_an_envelope_not_a_panic() {
let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "invalid_json");
}
#[test]
fn unknown_envelope_field_is_rejected() {
let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "invalid_json");
}
#[test]
fn unknown_command_lists_supported_ones() {
let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
assert_eq!(out["error"]["code"], "unknown_command");
assert!(out["error"]["message"]
.as_str()
.unwrap()
.contains("meta.capabilities"));
}
#[test]
fn wrong_api_version_is_rejected() {
let out = call(json!({"api_version": 99, "command": "meta.version"}));
assert_eq!(out["error"]["code"], "unsupported_api_version");
}
#[test]
fn oversize_request_is_a_resource_limit() {
let big = format!(
r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
"x".repeat(MAX_REQUEST_BYTES)
);
let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
assert_eq!(out["error"]["code"], "resource_limit");
}
#[test]
fn solve_without_payload_is_invalid_request() {
let out = call(json!({"api_version": 1, "command": "solve"}));
assert_eq!(out["error"]["code"], "invalid_request");
}
#[test]
fn profile_commands_without_payload_are_invalid_requests() {
for command in ["profile.validate", "profile.normalize"] {
let out = call(json!({"api_version": 1, "command": command}));
assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
assert!(
out["error"]["message"]
.as_str()
.unwrap()
.contains("ProfileData"),
"{command}: {out}"
);
}
}
#[test]
fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
let commands: Vec<String> =
serde_json::from_value(out["result"]["commands"].clone()).unwrap();
assert!(commands.contains(&"profile.validate".to_string()));
assert!(commands.contains(&"profile.normalize".to_string()));
assert_eq!(
commands.contains(&"profile.import_a7p".to_string()),
cfg!(feature = "profile-import"),
"profile.import_a7p must be listed exactly when compiled in"
);
assert_eq!(
commands.contains(&"profile.export_a7p".to_string()),
cfg!(feature = "profile-export"),
"profile.export_a7p must be listed exactly when compiled in"
);
assert_eq!(
commands.contains(&"bc5d.info".to_string()),
cfg!(not(target_arch = "wasm32")),
"bc5d.info must be listed exactly when the build has filesystem access"
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
assert_eq!(out["error"]["code"], "invalid_request", "{out}");
let out = call(json!({
"api_version": 1,
"command": "bc5d.info",
"request": {"path": "/nonexistent/bc5d_308.bin"}
}));
assert_eq!(out["error"]["code"], "command_failed", "{out}");
assert!(
out["error"]["message"]
.as_str()
.unwrap()
.contains("not a usable BC5D table"),
"{out}"
);
}
#[cfg(feature = "profile-import")]
#[test]
fn base64_decoder_round_trips_and_rejects_garbage() {
for (text, bytes) in [
("", &b""[..]),
("Zg==", b"f"),
("Zm8=", b"fo"),
("Zm9v", b"foo"),
("Zm9vYg==", b"foob"),
("Zm9vYmE=", b"fooba"),
("Zm9vYmFy", b"foobar"),
] {
assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
}
assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
assert!(
decode_base64("Zm9v!").is_err(),
"non-alphabet byte is rejected"
);
}
#[test]
fn capabilities_gates_card_pdf_on_the_pdf_feature() {
let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
let commands: Vec<String> =
serde_json::from_value(out["result"]["commands"].clone()).unwrap();
assert_eq!(
commands.contains(&"card.pdf".to_string()),
cfg!(feature = "pdf"),
"card.pdf must be listed exactly when compiled in: {out}"
);
let features: Vec<String> =
serde_json::from_value(out["result"]["features"].clone()).unwrap();
assert_eq!(
features.contains(&"pdf".to_string()),
cfg!(feature = "pdf"),
"the command list and the feature list must agree: {out}"
);
}
#[cfg(not(feature = "pdf"))]
#[test]
fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
let out = call(json!({
"api_version": 1,
"command": "card.pdf",
"request": {
"muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
"mass": 175.0, "diameter": 0.308,
"zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
}
}));
assert_eq!(out["error"]["code"], "unknown_command", "{out}");
}
#[test]
fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
let out = call(json!({
"api_version": 1,
"command": "card.range_table",
"request": {
"muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
"mass": 175.0, "diameter": 0.308,
"zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
"pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
}
}));
assert_eq!(out["ok"], true, "{out}");
assert_eq!(out["result"]["kind"], "range_table", "{out}");
}
#[cfg(feature = "pdf")]
#[test]
fn card_pdf_without_payload_is_invalid_request() {
let out = call(json!({"api_version": 1, "command": "card.pdf"}));
assert_eq!(out["error"]["code"], "invalid_request", "{out}");
assert!(
out["error"]["message"]
.as_str()
.unwrap()
.contains("card v1 document"),
"{out}"
);
}
#[cfg(feature = "pdf")]
#[test]
fn pdf_output_cap_refuses_only_over_the_limit() {
assert!(pdf_over_cap_error(0, 0, 0).is_none());
assert!(
pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
"a document exactly at the cap fits"
);
let envelope: Value =
serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
.unwrap();
assert_eq!(envelope["ok"], false);
assert_eq!(envelope["error"]["code"], "resource_limit");
let message = envelope["error"]["message"].as_str().unwrap();
assert!(message.contains("dope card"), "{envelope}");
assert!(message.contains("6 rows"), "{envelope}");
assert!(message.contains("1 pages"), "{envelope}");
for absent in ["coarsen", "shorten"] {
assert!(!message.contains(absent), "{envelope}");
}
}
#[cfg(feature = "pdf")]
#[test]
fn base64_encoder_matches_the_rfc_4648_vectors() {
for (bytes, text) in [
(&b""[..], ""),
(b"f", "Zg=="),
(b"fo", "Zm8="),
(b"foo", "Zm9v"),
(b"foob", "Zm9vYg=="),
(b"fooba", "Zm9vYmE="),
(b"foobar", "Zm9vYmFy"),
] {
assert_eq!(encode_base64(bytes), text, "{bytes:?}");
}
assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
}
#[cfg(all(feature = "pdf", feature = "profile-import"))]
#[test]
fn base64_encode_decode_round_trips_arbitrary_bytes() {
for len in 0..=32usize {
let bytes: Vec<u8> = (0..len)
.map(|i| (i as u8).wrapping_mul(37).wrapping_add(11))
.collect();
let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
assert_eq!(decoded, bytes, "len {len}");
}
}
#[test]
fn solve_with_bad_schema_carries_typed_details() {
let out = call(json!({
"api_version": 1,
"command": "solve",
"request": {"schema_version": 1, "unknown_field": true}
}));
assert_eq!(out["error"]["code"], "command_failed");
assert_eq!(out["error"]["details"]["status"], "error");
}
fn hash_reticle() -> Value {
json!({
"name": "test cross",
"focal_plane": "ffp",
"reference_magnification": 1.0,
"marks": [
{"down_mil": 0.0, "right_mil": 0.0, "kind": "center"},
{"down_mil": 2.0, "right_mil": 0.0, "kind": "hash", "label": "2 mil"},
{"down_mil": 4.0, "right_mil": 0.0, "kind": "hash"},
{"down_mil": 0.0, "right_mil": 2.0, "kind": "hash"},
],
})
}
#[test]
fn capabilities_lists_the_reticle_family() {
let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
let commands: Vec<String> =
serde_json::from_value(out["result"]["commands"].clone()).unwrap();
for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
assert!(
commands.contains(&command.to_string()),
"{command} dispatches but meta.capabilities does not list it"
);
}
}
#[test]
fn hold_reports_the_nearest_mark_and_its_label() {
let out = call(json!({
"api_version": 1,
"command": "reticle.hold",
"request": {
"reticle": hash_reticle(),
"drop_mil": 2.1,
"wind_mil": 0.0,
"magnification": 10.0,
},
}));
assert_eq!(out["ok"], true, "{out}");
assert_eq!(out["result"]["hold"]["down_mil"], 2.1);
assert_eq!(out["result"]["hold"]["right_mil"], 0.0);
assert_eq!(out["result"]["hold"]["off_reticle"], false);
assert_eq!(out["result"]["nearest_mark"]["index"], 1);
assert_eq!(out["result"]["nearest_mark"]["label"], "2 mil");
assert_eq!(out["result"]["nearest_mark"]["kind"], "hash");
}
#[test]
fn an_sfp_reticle_moves_its_marks_and_the_hold_follows() {
let mut sfp = hash_reticle();
sfp["focal_plane"] = json!("sfp");
sfp["reference_magnification"] = json!(10.0);
let at_reference = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 10.0},
}));
assert_eq!(at_reference["result"]["hold"]["mark_scale"], 1.0);
assert_eq!(at_reference["result"]["nearest_mark"]["index"], 1);
assert_eq!(
at_reference["result"]["nearest_mark"]["true_angular"]["down_mil"],
2.0
);
let zoomed_out = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 5.0},
}));
assert_eq!(zoomed_out["result"]["hold"]["mark_scale"], 2.0);
assert_eq!(
zoomed_out["result"]["nearest_mark"]["true_angular"]["down_mil"], 0.0,
"at 5x the nearest mark to a 2 mil hold is now CENTER, not the 2 mil hash"
);
assert_eq!(zoomed_out["result"]["nearest_mark"]["index"], 0);
assert_eq!(
zoomed_out["result"]["nearest_mark"]["nominal"]["down_mil"],
0.0
);
let on_the_scaled_hash = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"reticle": sfp, "drop_mil": 4.0, "magnification": 5.0},
}));
assert_eq!(
on_the_scaled_hash["result"]["nearest_mark"]["nominal"]["down_mil"], 2.0,
"as authored, the mark is at 2 mil"
);
assert_eq!(
on_the_scaled_hash["result"]["nearest_mark"]["true_angular"]["down_mil"], 4.0,
"at 5x it subtends 4 mil, which is what the hold is measured against"
);
assert_eq!(
on_the_scaled_hash["result"]["hold"]["nearest_mark_distance_mil"], 0.0,
"the hold lands exactly on it"
);
}
#[test]
fn a_hold_off_the_marked_part_says_so() {
let out = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"reticle": hash_reticle(), "drop_mil": 40.0, "magnification": 10.0},
}));
assert_eq!(out["ok"], true);
assert_eq!(out["result"]["hold"]["off_reticle"], true);
}
#[test]
fn describe_builds_from_a_generator_and_reports_the_plane() {
let out = call(json!({
"api_version": 1, "command": "reticle.describe",
"request": {"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0}},
}));
assert_eq!(out["ok"], true, "{out}");
assert_eq!(out["result"]["focal_plane"], "FFP");
assert_eq!(out["result"]["magnification_dependent"], false);
assert!(out["result"]["mark_count"].as_u64().unwrap() > 1);
assert!(out["result"]["scaled"].is_null());
}
#[test]
fn a_generated_reticle_can_be_made_sfp() {
let out = call(json!({
"api_version": 1, "command": "reticle.describe",
"request": {
"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
"focal_plane": "sfp",
"reference_magnification": 12.0,
"magnification": 6.0,
},
}));
assert_eq!(out["ok"], true, "{out}");
assert_eq!(out["result"]["focal_plane"], "SFP");
assert_eq!(out["result"]["magnification_dependent"], true);
assert_eq!(out["result"]["scaled"]["mark_scale"], 2.0);
}
#[test]
fn a_bdc_ladder_carries_its_range_labels() {
let out = call(json!({
"api_version": 1, "command": "reticle.describe",
"request": {"generator": {"kind": "bdc", "drops": [[300.0, 1.2], [400.0, 2.4]]}},
}));
assert_eq!(out["ok"], true, "{out}");
let marks = out["result"]["reticle"]["marks"]
.as_array()
.unwrap()
.clone();
let labels: Vec<&str> = marks.iter().filter_map(|m| m["label"].as_str()).collect();
assert_eq!(labels, vec!["300 m", "400 m"]);
}
#[test]
fn naming_a_reticle_twice_is_refused_rather_than_resolved() {
let out = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {
"reticle": hash_reticle(),
"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
"drop_mil": 1.0,
"magnification": 10.0,
},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "invalid_request");
assert!(out["error"]["message"]
.as_str()
.unwrap()
.contains("exactly one"));
}
#[test]
fn a_catalog_id_is_the_third_way_to_name_a_reticle() {
let out = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"catalog": "mil-dot", "drop_mil": 3.0, "magnification": 10.0},
}));
assert_eq!(out["ok"], true, "{out}");
assert_eq!(out["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
assert_eq!(out["result"]["nearest_mark"]["nominal"]["down_mil"], 3.0);
assert_eq!(out["result"]["nearest_mark"]["label"], "3 mil down");
}
#[test]
fn an_unknown_catalog_id_is_refused_rather_than_substituted() {
let out = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"catalog": "tremor-9000", "drop_mil": 3.0, "magnification": 10.0},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "invalid_request");
assert!(out["error"]["message"]
.as_str()
.unwrap()
.contains("reticle.catalog"));
}
#[test]
fn naming_a_reticle_three_ways_is_refused_too() {
let out = call(json!({
"api_version": 1, "command": "reticle.describe",
"request": {
"catalog": "mil-dot",
"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "invalid_request");
}
#[test]
fn the_catalog_lists_what_the_other_commands_will_accept() {
let out = call(json!({"api_version": 1, "command": "reticle.catalog"}));
assert_eq!(out["ok"], true, "{out}");
let reticles = out["result"]["reticles"].as_array().unwrap().clone();
assert!(!reticles.is_empty());
for entry in reticles {
let id = entry["id"].as_str().unwrap();
assert!(
!entry["source"].as_str().unwrap().is_empty(),
"{id} has no source"
);
assert!(
!entry["notes"].as_str().unwrap().is_empty(),
"{id} has no notes"
);
assert!(
entry["mark_count"].as_u64().unwrap() > 0,
"{id} has no marks"
);
let held = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"catalog": id, "drop_mil": 1.0, "magnification": 10.0},
}));
assert_eq!(held["ok"], true, "listed id {id} does not resolve: {held}");
}
}
#[test]
fn a_batch_of_holds_comes_back_in_request_order() {
let out = call(json!({
"api_version": 1, "command": "reticle.holds",
"request": {
"catalog": "mil-dot",
"magnification": 10.0,
"holds": [
{"drop_mil": 1.0},
{"drop_mil": 3.0, "wind_mil": 0.0},
{"drop_mil": 2.0},
],
},
}));
assert_eq!(out["ok"], true, "{out}");
let holds = out["holds"].clone();
assert!(holds.is_null(), "holds live under result, not the envelope");
let holds = out["result"]["holds"].as_array().unwrap().clone();
assert_eq!(holds.len(), 3);
assert_eq!(holds[0]["nearest_mark"]["nominal"]["down_mil"], 1.0);
assert_eq!(holds[1]["nearest_mark"]["nominal"]["down_mil"], 3.0);
assert_eq!(holds[2]["nearest_mark"]["nominal"]["down_mil"], 2.0);
assert_eq!(out["result"]["mark_scale"], 1.0);
assert!(holds[0]["hold"]["mark_scale"].is_null());
}
#[test]
fn a_batch_agrees_with_the_single_hold_command() {
let single = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"catalog": "mil-dot", "drop_mil": 2.4, "wind_mil": 0.7, "magnification": 10.0},
}));
let batch = call(json!({
"api_version": 1, "command": "reticle.holds",
"request": {
"catalog": "mil-dot", "magnification": 10.0,
"holds": [{"drop_mil": 2.4, "wind_mil": 0.7}],
},
}));
assert_eq!(single["ok"], true);
assert_eq!(batch["ok"], true);
assert_eq!(
single["result"]["nearest_mark"],
batch["result"]["holds"][0]["nearest_mark"]
);
assert_eq!(
single["result"]["hold"]["off_reticle"],
batch["result"]["holds"][0]["hold"]["off_reticle"]
);
assert_eq!(
single["result"]["hold"]["nearest_mark_distance_mil"],
batch["result"]["holds"][0]["hold"]["nearest_mark_distance_mil"]
);
}
#[test]
fn a_malformed_hold_entry_refuses_the_whole_batch() {
for bad in [
json!(null),
json!("3.0"),
json!({"drop_mil": 1.0, "nonsense": 2}),
] {
let out = call(json!({
"api_version": 1, "command": "reticle.holds",
"request": {
"catalog": "mil-dot", "magnification": 10.0,
"holds": [{"drop_mil": 1.0}, bad],
},
}));
assert_eq!(out["ok"], false, "accepted a malformed entry: {out}");
assert_eq!(out["error"]["code"], "invalid_request");
}
}
#[test]
fn a_bad_magnification_fails_the_batch_before_any_row() {
let out = call(json!({
"api_version": 1, "command": "reticle.holds",
"request": {
"catalog": "mil-dot", "magnification": 0.0,
"holds": [{"drop_mil": 1.0}, {"drop_mil": 2.0}],
},
}));
assert_eq!(out["ok"], false);
assert_eq!(
out["error"]["details"]["reason"],
"non_positive_magnification"
);
}
#[test]
fn an_empty_batch_is_an_empty_list_not_an_error() {
let out = call(json!({
"api_version": 1, "command": "reticle.holds",
"request": {"catalog": "mil-dot", "magnification": 10.0, "holds": []},
}));
assert_eq!(out["ok"], true, "{out}");
assert_eq!(out["result"]["holds"].as_array().unwrap().len(), 0);
}
#[test]
fn too_many_holds_is_a_resource_limit() {
let holds: Vec<Value> = (0..MAX_RETICLE_HOLDS + 1)
.map(|i| json!({"drop_mil": (i % 5) as f64}))
.collect();
let out = call(json!({
"api_version": 1, "command": "reticle.holds",
"request": {"catalog": "mil-dot", "magnification": 10.0, "holds": holds},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "resource_limit");
}
#[test]
fn capabilities_lists_the_catalog_command() {
let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
let commands: Vec<String> =
serde_json::from_value(out["result"]["commands"].clone()).unwrap();
assert!(commands.contains(&"reticle.catalog".to_string()));
assert!(commands.contains(&"reticle.holds".to_string()));
}
#[test]
fn naming_no_reticle_at_all_is_refused() {
let out = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"drop_mil": 1.0, "magnification": 10.0},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "invalid_request");
}
#[test]
fn plane_overrides_beside_a_full_reticle_are_refused() {
let out = call(json!({
"api_version": 1, "command": "reticle.describe",
"request": {"reticle": hash_reticle(), "focal_plane": "sfp"},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "invalid_request");
}
#[test]
fn a_reticle_error_carries_a_stable_reason_and_its_numbers() {
let out = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {"reticle": hash_reticle(), "drop_mil": 1.0, "magnification": 0.0},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "command_failed");
assert_eq!(
out["error"]["details"]["reason"],
"non_positive_magnification"
);
assert_eq!(out["error"]["details"]["magnification"], 0.0);
assert_eq!(out["error"]["details"]["command"], "reticle.hold");
}
#[test]
fn a_reticle_with_no_marks_is_refused_by_describe_too() {
let mut empty = hash_reticle();
empty["marks"] = json!([]);
let out = call(json!({
"api_version": 1, "command": "reticle.describe",
"request": {"reticle": empty},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["details"]["reason"], "no_marks");
}
#[test]
fn an_unreadable_generator_parameter_names_the_parameter() {
let out = call(json!({
"api_version": 1, "command": "reticle.describe",
"request": {"generator": {"kind": "mil_grid", "spacing_mil": 0.0, "extent_mil": 5.0}},
}));
assert_eq!(out["ok"], false);
assert_eq!(
out["error"]["details"]["reason"],
"invalid_generator_parameter"
);
assert_eq!(out["error"]["details"]["parameter"], "spacing");
}
#[test]
fn importing_a_ventum_document_returns_the_reticle_and_its_report() {
let document = r#"{"name":"MBR","plane":"ffp","unit":"mil","spec":[
{"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},
{"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},
{"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]}"#;
let out = call(json!({
"api_version": 1, "command": "reticle.import",
"request": {"format": "ventum", "document": document},
}));
assert_eq!(out["ok"], true, "{out}");
assert_eq!(out["result"]["format"], "ventum");
assert_eq!(out["result"]["mark_count"], 78);
assert_eq!(out["result"]["reticle"]["name"], "MBR");
assert_eq!(out["result"]["reticle"]["focal_plane"], "ffp");
assert_eq!(out["result"]["report"]["dropped_elements"], 0);
assert_eq!(out["result"]["report"]["arcs_unresolved"], 0);
let held = call(json!({
"api_version": 1, "command": "reticle.hold",
"request": {
"reticle": out["result"]["reticle"],
"drop_mil": 8.0,
"wind_mil": 2.0,
"magnification": 10.0,
},
}));
assert_eq!(held["ok"], true, "{held}");
assert_eq!(held["result"]["hold"]["off_reticle"], false);
assert_eq!(held["result"]["nearest_mark"]["nominal"]["down_mil"], 8.0);
assert_eq!(held["result"]["nearest_mark"]["nominal"]["right_mil"], 2.0);
assert_eq!(held["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
}
#[test]
fn a_document_that_is_not_the_named_format_fails_as_a_command_failure() {
let out = call(json!({
"api_version": 1, "command": "reticle.import",
"request": {"format": "reticle_xml", "document": "{\"this\": \"is json\"}"},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "command_failed");
}
#[test]
fn an_oversize_reticle_document_is_a_resource_limit_not_a_parse_failure() {
let out = call(json!({
"api_version": 1, "command": "reticle.import",
"request": {
"format": "ventum",
"document": "x".repeat(MAX_RETICLE_DOCUMENT_BYTES + 1),
},
}));
assert_eq!(out["ok"], false);
assert_eq!(out["error"]["code"], "resource_limit");
assert!(out["error"]["message"]
.as_str()
.unwrap()
.contains("reticle document"));
}
#[test]
fn every_reticle_command_rejects_an_unknown_request_field() {
for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
let out = call(json!({
"api_version": 1,
"command": command,
"request": {"nonsense": 1},
}));
assert_eq!(out["ok"], false, "{command} accepted an unknown field");
assert_eq!(out["error"]["code"], "invalid_request", "{command}");
}
}
#[test]
fn every_reticle_command_rejects_a_missing_payload() {
for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
let out = call(json!({"api_version": 1, "command": command}));
assert_eq!(out["ok"], false, "{command} accepted a null payload");
assert_eq!(out["error"]["code"], "invalid_request", "{command}");
}
}
#[cfg(all(feature = "profile-export", feature = "profile-import"))]
#[test]
fn export_a7p_produces_a_file_import_a7p_reads_back() {
let profile = json!({
"name": "bridge-export",
"velocity": 792.0,
"bc": 0.381,
"mass": 19.439673,
"diameter": 8.5852,
"drag_model": "G7",
"twist_rate": 254.0,
"sight_height": 90.0,
"zero_distance": 100.0,
"units": "metric",
"temperature": 15.0,
"pressure": 1000.0,
"humidity": 50.0,
"altitude": 0.0,
"bullet_name": "300GR OTM",
"twist_right": false,
"bullet_length": 45.72,
"elevation_cf": 0.97,
"dsf_points": [{"mach": 0.9, "dsf": 1.04}]
});
let out = call(json!({
"api_version": 1, "command": "profile.export_a7p", "request": profile
}));
assert_eq!(out["ok"], true, "{out}");
let result = &out["result"];
assert!(result["byte_length"].as_u64().unwrap() > 32);
let dropped: Vec<String> =
serde_json::from_value(result["dropped_fields"].clone()).unwrap();
assert!(dropped.contains(&"elevation_cf".to_string()), "{out}");
assert!(dropped.contains(&"dsf_points".to_string()), "{out}");
assert!(!dropped.contains(&"velocity".to_string()), "{out}");
let not_carried = result["not_carried"].as_array().unwrap();
let reticle = not_carried
.iter()
.find(|e| e["field"] == "reticle")
.expect("reticle is listed even though this profile has none");
assert_eq!(reticle["populated"], false, "{out}");
assert!(reticle["reason"].as_str().unwrap().len() > 10, "{out}");
let back = call(json!({
"api_version": 1,
"command": "profile.import_a7p",
"request": { "a7p_base64": result["a7p_base64"].clone(), "strict": true }
}));
assert_eq!(back["ok"], true, "{back}");
let reimported = &back["result"]["profile"];
assert_eq!(reimported["name"], "bridge-export", "{back}");
assert_eq!(reimported["drag_model"], "G7", "{back}");
assert_eq!(reimported["twist_right"], false, "{back}");
}
#[cfg(feature = "profile-export")]
#[test]
fn export_a7p_refuses_an_unrepresentable_drag_model_with_a_reason() {
let out = call(json!({
"api_version": 1,
"command": "profile.export_a7p",
"request": {
"name": "g5-load", "velocity": 792.0, "bc": 0.3, "mass": 19.4,
"diameter": 8.5852, "drag_model": "G5", "units": "metric",
"bullet_length": 45.72, "zero_distance": 100.0, "sight_height": 90.0,
"temperature": 15.0, "pressure": 1000.0, "humidity": 50.0
}
}));
assert_eq!(out["error"]["code"], "command_failed", "{out}");
assert_eq!(out["error"]["details"]["reason"], "drag_model", "{out}");
assert_eq!(out["error"]["details"]["drag_model"], "G5", "{out}");
}
#[cfg(feature = "profile-export")]
#[test]
fn export_a7p_without_a_payload_is_an_invalid_request() {
let out = call(json!({"api_version": 1, "command": "profile.export_a7p"}));
assert_eq!(out["error"]["code"], "invalid_request", "{out}");
assert!(
out["error"]["message"]
.as_str()
.unwrap()
.contains("ProfileData"),
"{out}"
);
}
}