#![cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;
use crate::transforms::TransformError;
use crate::wire::{
check_finite, check_kind, check_probability, distribution_refusal, interval_set_from, whole,
Bound, Code, Field, Refusal,
};
#[derive(Debug, Clone, PartialEq)]
enum Element {
Number(f64),
Other(String),
}
fn numbers_from(
parameter: &'static str,
elements: impl IntoIterator<Item = Element>,
) -> Result<Vec<f64>, Refusal> {
let mut out = Vec::new();
for (i, element) in elements.into_iter().enumerate() {
match element {
Element::Number(x) if x.is_finite() => out.push(x),
Element::Number(x) => {
return Err(Refusal {
code: Code::ValueNotFinite,
message: format!("{parameter}[{i}]: expected a finite number, got {x}"),
fields: vec![
("parameter", Field::Str(parameter)),
("index", Field::Num(i as f64)),
],
})
}
Element::Other(kind) => {
return Err(Refusal {
code: Code::MalformedInput,
message: format!("{parameter}[{i}]: expected a number, got {kind}"),
fields: vec![
("parameter", Field::Str(parameter)),
("index", Field::Num(i as f64)),
],
})
}
}
}
Ok(out)
}
fn read_numbers(value: &JsValue, parameter: &'static str) -> Result<Vec<f64>, Refusal> {
use wasm_bindgen::JsCast;
if let Some(typed) = value.dyn_ref::<js_sys::Float64Array>() {
return numbers_from(parameter, typed.to_vec().into_iter().map(Element::Number));
}
if !js_sys::Array::is_array(value) {
return Err(Refusal {
code: Code::MalformedInput,
message: format!("{parameter}: expected an array of numbers or a Float64Array"),
fields: vec![("parameter", Field::Str(parameter))],
});
}
let array: &js_sys::Array = value.unchecked_ref();
numbers_from(
parameter,
array.iter().map(|item| match item.as_f64() {
Some(x) => Element::Number(x),
None if item.is_null() => Element::Other("null".to_string()),
None => Element::Other(item.js_typeof().as_string().unwrap_or_default()),
}),
)
}
fn at_least(data: Vec<f64>, min: usize) -> Result<Vec<f64>, Refusal> {
if data.len() >= min {
Ok(data)
} else {
Err(Refusal {
code: Code::InsufficientData,
message: format!("data: at least {min} values are needed, got {}", data.len()),
fields: vec![
("parameter", Field::Str("data")),
("min", Field::Num(min as f64)),
("got", Field::Num(data.len() as f64)),
],
})
}
}
fn lambda_range_refusal(lambda_min: f64, lambda_max: f64) -> Refusal {
for (parameter, x) in [("lambda_min", lambda_min), ("lambda_max", lambda_max)] {
if !x.is_finite() {
return Refusal {
code: Code::ValueNotFinite,
message: format!("{parameter} must be a finite number, got {x}"),
fields: vec![("parameter", Field::Str(parameter))],
};
}
}
Refusal::out_of_range(
Field::Str("lambda_min"),
Bound::Unbounded,
Bound::Open(lambda_max),
lambda_min,
format!("lambda_min must be less than lambda_max ({lambda_max}), got {lambda_min}"),
)
}
impl From<TransformError> for Refusal {
fn from(e: TransformError) -> Self {
if let TransformError::InvalidLambdaRange { min, max } = e {
return lambda_range_refusal(min, max);
}
let data = ("parameter", Field::Str("data"));
let (code, fields) = match e {
TransformError::NonPositiveData { index, value } => (
Code::NonPositiveData,
vec![
data,
("index", Field::Num(index as f64)),
("got", Field::Num(value)),
],
),
TransformError::NonFiniteData { index } => (
Code::ValueNotFinite,
vec![data, ("index", Field::Num(index as f64))],
),
TransformError::InsufficientData { min, got } => (
Code::InsufficientData,
vec![
data,
("min", Field::Num(min as f64)),
("got", Field::Num(got as f64)),
],
),
TransformError::InvalidLambdaRange { .. } => {
unreachable!("refused with its range above")
}
TransformError::InvalidTransform => (Code::InvalidTransform, Vec::new()),
TransformError::InvalidInverse => (Code::InvalidInverse, Vec::new()),
};
Refusal {
code,
message: e.to_string(),
fields,
}
}
}
#[wasm_bindgen(unchecked_return_type = "Codes")]
pub fn codes() -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&crate::wire::code_catalog()).map_err(|e| {
JsValue::from(Refusal {
code: Code::Internal,
message: format!("internal: {e}"),
fields: vec![],
})
})
}
impl From<Refusal> for JsValue {
fn from(refusal: Refusal) -> JsValue {
debug_assert!(
refusal.fields_declared(),
"{} refusal with an undeclared field",
refusal.code.name()
);
let err = js_sys::Error::new(&refusal.message);
let _ = js_sys::Reflect::set(&err, &"code".into(), &refusal.code.name().into());
for (key, value) in refusal.fields {
let value = match value {
Field::Num(n) => JsValue::from_f64(n),
Field::Str(s) => s.into(),
Field::Path(s) => s.into(),
Field::List(names) => names
.iter()
.map(|n| JsValue::from_str(n))
.collect::<js_sys::Array>()
.into(),
Field::Null => JsValue::NULL,
};
let _ = js_sys::Reflect::set(&err, &key.into(), &value);
}
err.into()
}
}
#[wasm_bindgen]
pub fn mean(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
) -> Result<f64, JsValue> {
let data = read_numbers(&data, "data")?;
if data.is_empty() {
return Err(Refusal {
code: Code::EmptyInput,
message: "data: the mean of no values is undefined".to_string(),
fields: vec![("parameter", Field::Str("data"))],
}
.into());
}
Ok(crate::stats::mean(&data).expect("non-empty finite data has a mean"))
}
#[wasm_bindgen]
pub fn std_dev(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
) -> Result<f64, JsValue> {
let data = at_least(read_numbers(&data, "data")?, 2)?;
Ok(crate::stats::std_dev(&data).expect("two or more finite values have a std_dev"))
}
#[wasm_bindgen]
pub fn variance(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
) -> Result<f64, JsValue> {
let data = at_least(read_numbers(&data, "data")?, 2)?;
Ok(crate::stats::variance(&data).expect("two or more finite values have a variance"))
}
#[wasm_bindgen]
pub fn normal_cdf(x: f64) -> f64 {
crate::special::standard_normal_cdf(x)
}
#[wasm_bindgen]
pub fn normal_sf(x: f64) -> f64 {
crate::special::standard_normal_sf(x)
}
#[wasm_bindgen]
pub fn box_cox(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
lambda: f64,
) -> Result<Vec<f64>, JsValue> {
let data = read_numbers(&data, "data")?;
crate::transforms::box_cox(&data, lambda).map_err(|e| Refusal::from(e).into())
}
#[derive(serde::Serialize, tsify::Tsify)]
struct LambdaEstimateDto {
lambda: f64,
at_bound: bool,
}
#[wasm_bindgen(unchecked_return_type = "LambdaEstimateDto")]
pub fn estimate_lambda(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
lambda_min: f64,
lambda_max: f64,
) -> Result<JsValue, JsValue> {
let data = read_numbers(&data, "data")?;
let est =
crate::transforms::estimate_lambda(&data, lambda_min, lambda_max).map_err(Refusal::from)?;
serde_wasm_bindgen::to_value(&LambdaEstimateDto {
lambda: est.lambda,
at_bound: est.at_bound,
})
.map_err(|e| {
Refusal {
code: Code::Internal,
message: e.to_string(),
fields: vec![],
}
.into()
})
}
#[wasm_bindgen]
pub fn rfft(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
) -> Result<Vec<f64>, JsValue> {
let data = read_numbers(&data, "data")?;
Ok(crate::fourier::rfft(&data)
.into_iter()
.flat_map(|z| [z.re, z.im])
.collect())
}
fn check_df(name: &'static str, df: f64) -> Result<(), Refusal> {
if df.is_finite() && df > 0.0 {
Ok(())
} else {
Err(Refusal::out_of_range(
Field::Str(name),
Bound::Open(0.0),
Bound::Unbounded,
df,
format!("{name} must be a finite number > 0, got {df}"),
))
}
}
#[wasm_bindgen]
pub fn inverse_normal_cdf(p: f64) -> Result<f64, JsValue> {
check_probability(p)?;
Ok(crate::special::inverse_normal_cdf(p))
}
#[wasm_bindgen]
pub fn t_distribution_cdf(t: f64, df: f64) -> Result<f64, JsValue> {
check_finite("t", t)?;
check_df("df", df)?;
Ok(crate::special::t_distribution_cdf(t, df))
}
#[wasm_bindgen]
pub fn t_distribution_quantile(p: f64, df: f64) -> Result<f64, JsValue> {
check_probability(p)?;
check_df("df", df)?;
Ok(crate::special::t_distribution_quantile(p, df))
}
#[wasm_bindgen]
pub fn f_distribution_cdf(x: f64, df1: f64, df2: f64) -> Result<f64, JsValue> {
check_finite("x", x)?;
check_df("df1", df1)?;
check_df("df2", df2)?;
Ok(crate::special::f_distribution_cdf(x, df1, df2))
}
#[wasm_bindgen]
pub fn f_distribution_quantile(p: f64, df1: f64, df2: f64) -> Result<f64, JsValue> {
check_probability(p)?;
check_df("df1", df1)?;
check_df("df2", df2)?;
Ok(crate::special::f_distribution_quantile(p, df1, df2))
}
#[wasm_bindgen]
pub fn chi_squared_cdf(x: f64, k: f64) -> Result<f64, JsValue> {
check_finite("x", x)?;
check_df("k", k)?;
Ok(crate::special::chi_squared_cdf(x, k))
}
#[wasm_bindgen]
pub fn chi_squared_quantile(p: f64, k: f64) -> Result<f64, JsValue> {
check_probability(p)?;
check_df("k", k)?;
Ok(crate::special::chi_squared_quantile(p, k))
}
fn find_non_finite(value: &JsValue, parameter: &str) -> Option<(String, f64)> {
if let Some(n) = value.as_f64() {
return (!n.is_finite()).then(|| (parameter.to_string(), n));
}
if !value.is_object() {
return None;
}
let object: &js_sys::Object = wasm_bindgen::JsCast::unchecked_ref(value);
for entry in js_sys::Object::entries(object).iter() {
let pair: js_sys::Array = wasm_bindgen::JsCast::unchecked_into(entry);
let key = pair.get(0).as_string().unwrap_or_default();
let inner = find_non_finite(&pair.get(1), &format!("{parameter}.{key}"));
if inner.is_some() {
return inner;
}
}
None
}
fn read_distribution(value: &JsValue) -> Result<crate::wire::Distribution, Refusal> {
const P: &str = "distribution";
let malformed = |message: String| Refusal {
code: Code::MalformedInput,
message,
fields: vec![("parameter", Field::Str(P))],
};
if !value.is_object() {
return Err(malformed(format!(
"{P}: expected an object such as {{ kind: \"normal\", mu: 0, sigma: 1 }}"
)));
}
let kind = js_sys::Reflect::get(value, &"kind".into()).unwrap_or(JsValue::UNDEFINED);
let Some(kind) = kind.as_string() else {
return Err(Refusal {
code: Code::MalformedInput,
message: format!("{P}.kind: expected a string naming the distribution"),
fields: vec![("parameter", Field::Str("distribution.kind"))],
});
};
check_kind(&kind)?;
if let Some((path, n)) = find_non_finite(value, P) {
return Err(Refusal {
code: Code::ValueNotFinite,
message: format!("{path}: expected a finite number, got {n}"),
fields: vec![("parameter", Field::Path(path))],
});
}
let spec: crate::wire::DistributionSpec = serde_wasm_bindgen::from_value(value.clone())
.map_err(|e| {
let text = e.to_string();
malformed(format!("{P}: {}", text.trim_start_matches("Error: ")))
})?;
spec.build().map_err(distribution_refusal)
}
#[derive(serde::Serialize, tsify::Tsify)]
struct MomentsDto {
mean: f64,
variance: f64,
}
#[wasm_bindgen(unchecked_return_type = "MomentsDto")]
pub fn distribution_moments(
#[wasm_bindgen(unchecked_param_type = "DistributionSpec")] distribution: JsValue,
) -> Result<JsValue, JsValue> {
let d = read_distribution(&distribution)?;
let dto = MomentsDto {
mean: d.mean(),
variance: d.variance(),
};
Ok(serde_wasm_bindgen::to_value(&dto).map_err(|e| Refusal {
code: Code::Internal,
message: e.to_string(),
fields: Vec::new(),
})?)
}
#[wasm_bindgen]
pub fn distribution_cdf(
#[wasm_bindgen(unchecked_param_type = "DistributionSpec")] distribution: JsValue,
x: f64,
) -> Result<f64, JsValue> {
let d = read_distribution(&distribution)?;
check_finite("x", x)?;
Ok(d.cdf(x))
}
#[wasm_bindgen]
pub fn distribution_quantile(
#[wasm_bindgen(unchecked_param_type = "DistributionSpec")] distribution: JsValue,
p: f64,
) -> Result<f64, JsValue> {
let d = read_distribution(&distribution)?;
check_probability(p)?;
Ok(d.quantile(p))
}
#[wasm_bindgen]
pub fn distribution_sample(
#[wasm_bindgen(unchecked_param_type = "DistributionSpec")] distribution: JsValue,
n: f64,
seed: f64,
) -> Result<Vec<f64>, JsValue> {
let d = read_distribution(&distribution)?;
let n = whole("n", n, u32::MAX as f64)? as usize;
let seed = whole("seed", seed, 9_007_199_254_740_992.0)? as u64;
Ok(d.sample_n(n, seed))
}
fn read_intervals(
value: &JsValue,
parameter: &'static str,
) -> Result<crate::collections::IntervalSet<f64>, Refusal> {
use wasm_bindgen::JsCast;
if !js_sys::Array::is_array(value) {
return Err(Refusal {
code: Code::MalformedInput,
message: format!("{parameter}: expected an array of [start, end] pairs"),
fields: vec![("parameter", Field::Str(parameter))],
});
}
let array: &js_sys::Array = value.unchecked_ref();
let mut rows = Vec::with_capacity(array.length() as usize);
for (i, item) in array.iter().enumerate() {
let row = read_numbers(&item, parameter).map_err(|mut r| {
let path = format!("{parameter}[{i}]");
r.message = r.message.replacen(parameter, &path, 1);
for field in &mut r.fields {
if field.0 == "parameter" {
field.1 = Field::Path(path.clone());
}
}
r
})?;
rows.push(row);
}
interval_set_from(parameter, rows)
}
fn intervals_to_js(set: &crate::collections::IntervalSet<f64>) -> JsValue {
set.iter()
.map(|(s, e)| js_sys::Array::of2(&JsValue::from_f64(s), &JsValue::from_f64(e)))
.collect::<js_sys::Array>()
.into()
}
#[wasm_bindgen(unchecked_return_type = "[number, number][]")]
pub fn interval_normalize(
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] intervals: JsValue,
) -> Result<JsValue, JsValue> {
Ok(intervals_to_js(&read_intervals(&intervals, "intervals")?))
}
#[wasm_bindgen]
pub fn interval_measure(
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] intervals: JsValue,
) -> Result<f64, JsValue> {
Ok(read_intervals(&intervals, "intervals")?.measure())
}
#[wasm_bindgen(unchecked_return_type = "[number, number][]")]
pub fn interval_union(
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] a: JsValue,
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] b: JsValue,
) -> Result<JsValue, JsValue> {
let (a, b) = (read_intervals(&a, "a")?, read_intervals(&b, "b")?);
Ok(intervals_to_js(&a.union(&b)))
}
#[wasm_bindgen(unchecked_return_type = "[number, number][]")]
pub fn interval_intersection(
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] a: JsValue,
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] b: JsValue,
) -> Result<JsValue, JsValue> {
let (a, b) = (read_intervals(&a, "a")?, read_intervals(&b, "b")?);
Ok(intervals_to_js(&a.intersection(&b)))
}
#[wasm_bindgen(unchecked_return_type = "[number, number][]")]
pub fn interval_difference(
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] a: JsValue,
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] b: JsValue,
) -> Result<JsValue, JsValue> {
let (a, b) = (read_intervals(&a, "a")?, read_intervals(&b, "b")?);
Ok(intervals_to_js(&a.difference(&b)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_interval_row_that_is_not_a_pair_names_its_path() {
let r = interval_set_from("a", vec![vec![0.0, 1.0], vec![2.0]]).expect_err("short row");
assert_eq!(r.code, "malformed_input");
assert_eq!(r.fields, vec![("parameter", Field::Path("a[1]".into()))]);
assert_eq!(r.message, "a[1]: expected [start, end], got 1 numbers");
}
#[test]
fn a_reversed_interval_is_refused_at_its_index() {
let r = interval_set_from("b", vec![vec![0.0, 1.0], vec![5.0, 3.0]]).expect_err("reversed");
assert_eq!(r.code, "reversed_interval");
assert_eq!(
r.fields,
vec![("parameter", Field::Str("b")), ("index", Field::Num(1.0))]
);
}
#[test]
fn interval_rows_build_a_normal_form_set() {
let s = interval_set_from("a", vec![vec![3.0, 5.0], vec![0.0, 4.0]]).expect("valid");
assert_eq!(s.as_slice(), &[(0.0, 5.0)]);
}
#[test]
fn a_probability_outside_0_1_names_the_range() {
let r = check_probability(1.5).expect_err("outside");
assert_eq!(r.code, "parameter_out_of_range");
assert_eq!(
r.fields,
vec![
("parameter", Field::Str("p")),
("min", Field::Null),
("exclusive_min", Field::Num(0.0)),
("max", Field::Null),
("exclusive_max", Field::Num(1.0)),
("got", Field::Num(1.5)),
]
);
assert!(check_probability(0.5).is_ok());
}
#[test]
fn degrees_of_freedom_exclude_zero_and_are_open_above() {
let r = check_df("df2", 0.0).expect_err("zero");
assert_eq!(
r.fields,
vec![
("parameter", Field::Str("df2")),
("min", Field::Null),
("exclusive_min", Field::Num(0.0)),
("max", Field::Null),
("got", Field::Num(0.0)),
]
);
}
#[test]
fn a_closed_range_carries_no_exclusive_bounds() {
let r = whole("seed", -1.0, 10.0).expect_err("negative");
assert_eq!(
r.fields,
vec![
("parameter", Field::Str("seed")),
("min", Field::Num(0.0)),
("max", Field::Num(10.0)),
("got", Field::Num(-1.0)),
]
);
}
#[test]
fn a_fraction_is_not_a_whole_number_and_not_out_of_range() {
let r = whole("n", 2.5, 10.0).expect_err("fraction");
assert_eq!(r.code, "malformed_input");
assert_eq!(r.fields, vec![("parameter", Field::Str("n"))]);
assert_eq!(r.message, "n must be a whole number, got 2.5");
assert_eq!(
whole("n", -0.5, 10.0).expect_err("fraction").code,
"malformed_input"
);
}
#[test]
fn a_whole_number_above_the_range_names_it() {
let r = whole("n", 11.0, 10.0).expect_err("above");
assert_eq!(r.code, "parameter_out_of_range");
assert_eq!(
r.fields,
vec![
("parameter", Field::Str("n")),
("min", Field::Num(0.0)),
("max", Field::Num(10.0)),
("got", Field::Num(11.0)),
]
);
assert_eq!(whole("n", 3.0, 10.0), Ok(3.0));
assert_eq!(whole("n", 0.0, 10.0), Ok(0.0));
assert_eq!(whole("n", 10.0, 10.0), Ok(10.0));
}
#[test]
fn a_non_finite_count_is_not_finite() {
for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let r = whole("seed", x, 10.0).expect_err("non-finite");
assert_eq!(r.code, "value_not_finite");
assert_eq!(r.fields, vec![("parameter", Field::Str("seed"))]);
}
}
#[test]
fn nan_is_refused_as_not_finite() {
assert_eq!(
check_finite("x", f64::NAN).expect_err("NaN").code,
"value_not_finite"
);
assert!(check_finite("x", f64::INFINITY).is_ok());
}
#[test]
fn a_number_array_is_read_as_sent() {
let ok = numbers_from("data", [1.0, -2.5].map(Element::Number)).expect("numbers");
assert_eq!(ok, vec![1.0, -2.5]);
assert!(numbers_from("data", []).expect("empty").is_empty());
}
#[test]
fn null_is_refused_where_it_sits_not_read_as_zero() {
let r = numbers_from(
"data",
[
Element::Number(1.0),
Element::Other("null".into()),
Element::Number(3.0),
],
)
.expect_err("null");
assert_eq!(r.code, "malformed_input");
assert_eq!(
r.fields,
vec![
("parameter", Field::Str("data")),
("index", Field::Num(1.0))
]
);
assert!(r.message.contains("got null"), "{}", r.message);
}
#[test]
fn a_non_finite_element_is_not_finite_with_its_index() {
let r = numbers_from("data", [0.0, f64::INFINITY].map(Element::Number)).expect_err("inf");
assert_eq!(r.code, "value_not_finite");
assert_eq!(r.fields[1], ("index", Field::Num(1.0)));
}
#[test]
fn too_few_values_say_how_many_are_needed() {
let r = at_least(vec![1.0], 2).expect_err("one");
assert_eq!(r.code, "insufficient_data");
assert_eq!(r.fields[1], ("min", Field::Num(2.0)));
assert_eq!(r.fields[2], ("got", Field::Num(1.0)));
assert!(at_least(vec![1.0, 2.0], 2).is_ok());
}
#[test]
fn a_lambda_range_that_is_empty_is_a_range_refusal() {
for (lo, hi) in [(2.0, -2.0), (1.0, 1.0)] {
let r = Refusal::from(TransformError::InvalidLambdaRange { min: lo, max: hi });
assert_eq!(r.code, "parameter_out_of_range");
assert_eq!(
r.fields,
vec![
("parameter", Field::Str("lambda_min")),
("min", Field::Null),
("max", Field::Null),
("exclusive_max", Field::Num(hi)),
("got", Field::Num(lo)),
]
);
}
let r = Refusal::from(
crate::transforms::estimate_lambda(&[1.0, 2.0, 3.0], 2.0, -2.0).unwrap_err(),
);
assert_eq!(r.code, "parameter_out_of_range");
for (lo, hi, named) in [
(f64::NAN, 2.0, "lambda_min"),
(-2.0, f64::INFINITY, "lambda_max"),
] {
let r = Refusal::from(TransformError::InvalidLambdaRange { min: lo, max: hi });
assert_eq!(r.code, "value_not_finite");
assert_eq!(r.fields, vec![("parameter", Field::Str(named))]);
}
}
#[test]
fn transform_errors_keep_their_reason() {
let r = Refusal::from(TransformError::NonPositiveData {
index: 2,
value: -0.5,
});
assert_eq!(r.code, "non_positive_data");
assert_eq!(
r.message,
TransformError::NonPositiveData {
index: 2,
value: -0.5
}
.to_string()
);
}
}
#[cfg(test)]
mod code_catalog_tests {
#[test]
fn the_readme_documents_every_code_and_no_other() {
let readme = include_str!("../README.md");
let mut documented = std::collections::BTreeSet::new();
let mut in_table = false;
for line in readme.lines() {
if line.starts_with("| `code` |") {
in_table = true;
continue;
}
if !line.starts_with('|') {
in_table = false;
continue;
}
if in_table && !line.starts_with("|---") {
let first = line.split('|').nth(1).expect("a cell");
for code in first.split('·') {
documented.insert(code.trim().trim_matches('`').to_string());
}
}
}
let catalog = crate::wire::code_catalog();
let published: std::collections::BTreeSet<String> = catalog
.refusals
.iter()
.map(|r| r.code.clone())
.chain(catalog.violations.iter().map(|v| v.code.clone()))
.collect();
assert_eq!(documented, published);
}
}