#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use std::fmt;
pub mod from_str;
pub mod parse_error;
pub mod serde_string;
pub mod testable;
pub use testable::*;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Serialize)]
pub struct NHSNumber {
pub digits: [i8; 10],
}
impl NHSNumber {
#[allow(dead_code)]
pub fn new(digits: [i8; 10]) -> Self {
NHSNumber { digits }
}
#[allow(dead_code)]
pub fn check_digit(&self) -> i8 {
crate::check_digit(self.digits)
}
#[allow(dead_code)]
pub fn calculate_check_digit(&self) -> i8 {
crate::calculate_check_digit(self.digits)
}
#[allow(dead_code)]
pub fn validate_check_digit(&self) -> bool {
crate::validate_check_digit(self.digits)
}
#[allow(dead_code)]
pub fn is_issuable_range(&self) -> bool {
crate::is_issuable_range(self.digits)
}
#[allow(dead_code)]
pub fn testable_random_sample() -> NHSNumber {
crate::testable_random_sample()
}
}
impl fmt::Display for NHSNumber {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"{}{}{} {}{}{} {}{}{}{}",
self.digits[0],
self.digits[1],
self.digits[2],
self.digits[3],
self.digits[4],
self.digits[5],
self.digits[6],
self.digits[7],
self.digits[8],
self.digits[9],
)
}
}
impl From<NHSNumber> for String {
fn from(n: NHSNumber) -> String {
n.to_string()
}
}
impl<'de> Deserialize<'de> for NHSNumber {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename = "NHSNumber")]
struct Raw {
digits: [i8; 10],
}
let Raw { digits } = Raw::deserialize(deserializer)?;
if digits.iter().any(|d| !(0..=9).contains(d)) {
return Err(serde::de::Error::custom(
"NHS Number digit out of range 0..=9",
));
}
Ok(NHSNumber { digits })
}
}
#[allow(dead_code)]
pub fn format(digits: [i8; 10]) -> String {
format!(
"{}{}{} {}{}{} {}{}{}{}",
digits[0],
digits[1],
digits[2],
digits[3],
digits[4],
digits[5],
digits[6],
digits[7],
digits[8],
digits[9],
)
}
#[allow(dead_code)]
pub fn check_digit(digits: [i8; 10]) -> i8 {
digits[9]
}
#[allow(dead_code)]
pub fn calculate_check_digit(digits: [i8; 10]) -> i8 {
let sum: i64 = digits
.iter()
.take(9)
.enumerate()
.map(|(i, &d)| d as i64 * (10 - i as i64))
.sum();
let raw = 11 - sum.rem_euclid(11);
if raw == 11 { 0 } else { raw as i8 }
}
#[allow(dead_code)]
pub fn validate_check_digit(digits: [i8; 10]) -> bool {
crate::check_digit(digits) == crate::calculate_check_digit(digits)
}
#[allow(dead_code)]
pub fn is_issuable_range(digits: [i8; 10]) -> bool {
let mut n9: i64 = 0;
for &d in digits.iter().take(9) {
if !(0..=9).contains(&d) {
return false;
}
n9 = n9 * 10 + d as i64;
}
(311_300_000..=320_000_000).contains(&n9)
|| (400_000_000..=499_999_999).contains(&n9)
|| (600_000_000..=799_999_999).contains(&n9)
}
#[cfg(test)]
mod tests {
mod structure {
use super::super::*;
#[test]
fn test_new() {
let a: NHSNumber = NHSNumber::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
let actual = a.to_string();
let expect = "012 345 6789";
assert_eq!(actual, expect);
}
#[test]
fn test_new_preserves_digits() {
let digits = [9, 4, 3, 4, 7, 6, 5, 9, 1, 9];
let n = NHSNumber::new(digits);
assert_eq!(n.digits, digits);
}
#[test]
fn test_struct_literal_construction() {
let a = NHSNumber {
digits: [9, 9, 9, 1, 0, 0, 0, 0, 0, 3],
};
let b = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
assert_eq!(a, b);
}
#[test]
fn test_display() {
let a: NHSNumber = NHSNumber::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
let actual = a.to_string();
let expect = "012 345 6789";
assert_eq!(actual, expect);
}
#[test]
fn test_display_length_is_always_twelve() {
for first in 0..=9 {
let n = NHSNumber::new([first as i8; 10]);
assert_eq!(n.to_string().chars().count(), 12);
}
}
#[test]
fn test_display_spaces_at_positions_3_and_7() {
let n = NHSNumber::new([9, 4, 3, 4, 7, 6, 5, 9, 1, 9]);
let s = n.to_string();
let bytes = s.as_bytes();
assert_eq!(bytes[3], b' ');
assert_eq!(bytes[7], b' ');
for (i, b) in bytes.iter().enumerate() {
if i == 3 || i == 7 {
continue;
}
assert!(b.is_ascii_digit(), "byte at {i} should be ASCII digit");
}
}
#[test]
fn test_into_string() {
let a: NHSNumber = NHSNumber::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
let actual: String = a.into();
let expect = "012 345 6789";
assert_eq!(actual, expect);
}
#[test]
fn test_string_from() {
let n = NHSNumber::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
let actual = String::from(n);
let expect = "012 345 6789";
assert_eq!(actual, expect);
}
#[test]
fn test_into_string_agrees_with_display() {
let n = NHSNumber::new([9, 4, 3, 4, 7, 6, 5, 9, 1, 9]);
let via_into: String = n.into();
let via_display = n.to_string();
assert_eq!(via_into, via_display);
}
#[test]
fn test_string_from_agrees_with_display_and_into() {
let n = NHSNumber::new([9, 4, 3, 4, 7, 6, 5, 9, 1, 9]);
let via_from = String::from(n);
let via_into: String = n.into();
let via_display = n.to_string();
assert_eq!(via_from, via_display);
assert_eq!(via_into, via_display);
assert_eq!(via_from, via_into);
}
#[test]
fn test_partial_eq() {
{
let a: NHSNumber = NHSNumber::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
let b: NHSNumber = NHSNumber::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
assert_eq!(a, b);
}
{
let a: NHSNumber = NHSNumber::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
let b: NHSNumber = NHSNumber::new([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
assert_ne!(a, b);
}
}
#[test]
fn test_partial_eq_per_position() {
let base = [0i8; 10];
for i in 0..10 {
let mut other = base;
other[i] = 1;
assert_ne!(NHSNumber::new(base), NHSNumber::new(other));
}
}
#[test]
fn test_check_digit() {
let a = NHSNumber::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
let actual: i8 = a.check_digit();
let expect: i8 = 9;
assert_eq!(actual, expect);
}
#[test]
fn test_check_digit_reads_tenth_position() {
for tenth in 0i8..=9 {
let n = NHSNumber::new([0, 0, 0, 0, 0, 0, 0, 0, 0, tenth]);
assert_eq!(n.check_digit(), tenth);
}
}
#[test]
fn test_calculate_check_digit() {
let a: NHSNumber = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
assert_eq!(a.calculate_check_digit(), 3);
let b: NHSNumber = NHSNumber::new([9, 4, 3, 4, 7, 6, 5, 9, 1, 9]);
assert_eq!(b.calculate_check_digit(), 9);
let c: NHSNumber = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 1, 0, 0]);
assert_eq!(c.calculate_check_digit(), 0);
let d: NHSNumber = NHSNumber::new([9, 9, 9, 1, 2, 3, 4, 5, 6, 0]);
assert_eq!(d.calculate_check_digit(), 10);
}
#[test]
fn test_calculate_check_digit_ignores_tenth_position() {
for stored in 0i8..=9 {
let n = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, stored]);
assert_eq!(n.calculate_check_digit(), 3);
}
}
#[test]
fn test_validate_check_digit() {
{
let a: NHSNumber = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
assert!(a.validate_check_digit());
}
{
let a: NHSNumber = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 4]);
assert!(!a.validate_check_digit());
}
{
for stored in 0i8..=9 {
let n = NHSNumber::new([9, 9, 9, 1, 2, 3, 4, 5, 6, stored]);
assert!(
!n.validate_check_digit(),
"999 123 456{stored} must be invalid (sum % 11 == 1)"
);
}
}
}
#[test]
fn test_testable_random_sample() {
let a: NHSNumber = NHSNumber::testable_random_sample();
assert!(a >= *crate::testable::TESTABLE_MIN);
assert!(a <= *crate::testable::TESTABLE_MAX);
}
#[test]
fn test_testable_random_sample_starts_with_999() {
for _ in 0..32 {
let n = NHSNumber::testable_random_sample();
assert_eq!(&n.digits[0..3], &[9, 9, 9]);
}
}
}
mod utilities {
#[test]
fn test_format() {
let digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let actual = crate::format(digits);
let expect = "012 345 6789";
assert_eq!(actual, expect);
}
#[test]
fn test_format_all_zeros() {
assert_eq!(crate::format([0; 10]), "000 000 0000");
}
#[test]
fn test_format_all_nines() {
assert_eq!(crate::format([9; 10]), "999 999 9999");
}
#[test]
fn test_check_digit() {
let digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let actual: i8 = crate::check_digit(digits);
let expect: i8 = 9;
assert_eq!(actual, expect);
}
#[test]
fn test_calculate_check_digit() {
let digits = [9, 9, 9, 1, 0, 0, 0, 0, 0, 3];
assert_eq!(crate::calculate_check_digit(digits), 3);
let digits = [9, 9, 9, 1, 0, 0, 0, 1, 0, 0];
assert_eq!(crate::calculate_check_digit(digits), 0);
let digits = [9, 9, 9, 1, 2, 3, 4, 5, 6, 0];
assert_eq!(crate::calculate_check_digit(digits), 10);
}
#[test]
fn test_calculate_check_digit_all_zeros() {
assert_eq!(crate::calculate_check_digit([0; 10]), 0);
}
#[test]
fn test_validate_check_digit_free_fn() {
assert!(crate::validate_check_digit([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]));
assert!(!crate::validate_check_digit([9, 9, 9, 1, 0, 0, 0, 0, 0, 4]));
}
}
mod properties {
use super::super::*;
use std::str::FromStr;
fn sample_fixtures() -> Vec<NHSNumber> {
vec![
NHSNumber::new([0; 10]),
NHSNumber::new([9; 10]),
NHSNumber::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
NHSNumber::new([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]),
NHSNumber::new([9, 4, 3, 4, 7, 6, 5, 9, 1, 9]), NHSNumber::new([9, 8, 7, 6, 5, 4, 4, 3, 2, 1]), NHSNumber::new([9, 9, 9, 0, 0, 0, 0, 0, 0, 0]), NHSNumber::new([9, 9, 9, 9, 9, 9, 9, 9, 9, 9]), NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]), ]
}
#[test]
fn round_trip_via_canonical_form() {
for n in sample_fixtures() {
let s = n.to_string();
let parsed = NHSNumber::from_str(&s).expect("display form must parse");
assert_eq!(parsed, n, "round-trip failed for {s}");
}
}
#[test]
fn round_trip_via_tight_form() {
for n in sample_fixtures() {
let tight: String = n.digits.iter().map(|d| (b'0' + *d as u8) as char).collect();
let parsed = NHSNumber::from_str(&tight).expect("tight form must parse");
assert_eq!(parsed, n, "round-trip failed for {tight}");
}
}
#[test]
fn method_and_free_fn_format_agree() {
for n in sample_fixtures() {
assert_eq!(n.to_string(), crate::format(n.digits));
}
}
#[test]
fn method_and_free_fn_check_digit_agree() {
for n in sample_fixtures() {
assert_eq!(n.check_digit(), crate::check_digit(n.digits));
}
}
#[test]
fn method_and_free_fn_calculate_check_digit_agree() {
for n in sample_fixtures() {
assert_eq!(
n.calculate_check_digit(),
crate::calculate_check_digit(n.digits)
);
}
}
#[test]
fn method_and_free_fn_validate_check_digit_agree() {
for n in sample_fixtures() {
assert_eq!(
n.validate_check_digit(),
crate::validate_check_digit(n.digits)
);
}
}
#[test]
fn calculate_check_digit_is_in_valid_range() {
for n in sample_fixtures() {
let c = n.calculate_check_digit();
assert!((0..=10).contains(&c), "{c} out of [0..=10]");
}
}
}
mod boundaries {
use super::super::*;
#[test]
fn sum_mod_11_eq_0_yields_check_digit_zero() {
let digits = [9, 9, 9, 1, 0, 0, 0, 1, 0, 0];
assert_eq!(crate::calculate_check_digit(digits), 0);
}
#[test]
fn sum_mod_11_eq_1_yields_sentinel_ten() {
let digits = [9, 9, 9, 1, 2, 3, 4, 5, 6, 0];
assert_eq!(crate::calculate_check_digit(digits), 10);
}
#[test]
fn sum_mod_11_in_2_to_10_yields_eleven_minus_remainder() {
for d8 in 0i8..=9 {
let digits = [0, 0, 0, 0, 0, 0, 0, 0, d8, 0];
let sum = d8 as usize * 2;
let raw = 11 - (sum % 11);
let expected = if raw == 11 {
0
} else if raw == 10 {
10
} else {
raw as i8
};
assert_eq!(
crate::calculate_check_digit(digits),
expected,
"for d[8]={d8}"
);
}
}
#[test]
fn all_zeros_round_trips() {
let zeros = NHSNumber::new([0; 10]);
assert_eq!(zeros.to_string(), "000 000 0000");
assert!(zeros.validate_check_digit());
}
#[test]
fn all_nines_round_trips() {
let nines = NHSNumber::new([9; 10]);
assert_eq!(nines.to_string(), "999 999 9999");
assert_eq!(nines.calculate_check_digit(), 9);
assert!(nines.validate_check_digit());
}
}
mod ordering {
use super::super::*;
use std::collections::{BTreeMap, BTreeSet};
#[test]
fn ord_matches_numeric_intuition() {
let lo = NHSNumber::new([0; 10]);
let mid = NHSNumber::new([5; 10]);
let hi = NHSNumber::new([9; 10]);
assert!(lo < mid);
assert!(mid < hi);
assert!(lo < hi);
}
#[test]
fn ord_breaks_ties_left_to_right() {
let a = NHSNumber::new([1, 2, 3, 4, 5, 6, 7, 8, 9, 0]);
let b = NHSNumber::new([1, 2, 3, 4, 5, 7, 7, 8, 9, 0]);
assert!(a < b);
}
#[test]
fn vec_sort_is_ascending() {
let mut v = vec![
NHSNumber::new([9; 10]),
NHSNumber::new([0; 10]),
NHSNumber::new([5; 10]),
];
v.sort();
assert_eq!(
v,
vec![
NHSNumber::new([0; 10]),
NHSNumber::new([5; 10]),
NHSNumber::new([9; 10]),
]
);
}
#[test]
fn btreeset_dedups_and_orders() {
let mut set = BTreeSet::new();
set.insert(NHSNumber::new([9; 10]));
set.insert(NHSNumber::new([0; 10]));
set.insert(NHSNumber::new([0; 10])); assert_eq!(set.len(), 2);
let first = set.iter().next().copied().unwrap();
assert_eq!(first, NHSNumber::new([0; 10]));
}
#[test]
fn btreemap_use_as_key() {
let mut map: BTreeMap<NHSNumber, &'static str> = BTreeMap::new();
map.insert(NHSNumber::new([0; 10]), "min");
map.insert(NHSNumber::new([9; 10]), "max");
assert_eq!(map.get(&NHSNumber::new([0; 10])), Some(&"min"));
assert_eq!(map.get(&NHSNumber::new([9; 10])), Some(&"max"));
}
}
mod traits {
use super::super::*;
fn assert_copy<T: Copy>() {}
fn assert_clone<T: Clone>() {}
fn assert_send_sync<T: Send + Sync>() {}
fn assert_serde<T: serde::Serialize + serde::de::DeserializeOwned>() {}
fn assert_hash<T: std::hash::Hash>() {}
#[test]
fn nhs_number_is_copy_clone_send_sync_serde() {
assert_copy::<NHSNumber>();
assert_clone::<NHSNumber>();
assert_send_sync::<NHSNumber>();
assert_serde::<NHSNumber>();
assert_hash::<NHSNumber>();
}
#[test]
fn hash_is_consistent_with_eq() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]));
set.insert(NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3])); set.insert(NHSNumber::new([9, 4, 3, 4, 7, 6, 5, 9, 1, 9]));
assert_eq!(set.len(), 2);
}
#[test]
fn hashmap_use_as_key() {
use std::collections::HashMap;
let mut map: HashMap<NHSNumber, &'static str> = HashMap::new();
map.insert(NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]), "valid");
map.insert(NHSNumber::new([9, 9, 9, 1, 2, 3, 4, 5, 6, 0]), "sentinel");
assert_eq!(
map.get(&NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3])),
Some(&"valid")
);
assert_eq!(
map.get(&NHSNumber::new([9, 9, 9, 1, 2, 3, 4, 5, 6, 0])),
Some(&"sentinel")
);
}
#[test]
fn copy_does_not_move() {
let a = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
let b = a;
assert_eq!(a, b);
}
#[test]
fn clone_produces_equal_value() {
let a = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
#[allow(clippy::clone_on_copy)]
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn debug_format_is_non_empty() {
let n = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
let dbg = format!("{n:?}");
assert!(dbg.contains("NHSNumber"));
assert!(dbg.contains("digits"));
}
}
mod adversarial {
use super::super::*;
fn hostile_fixtures() -> Vec<[i8; 10]> {
vec![
[i8::MIN; 10],
[i8::MAX; 10],
[-1; 10],
[10; 10],
[i8::MIN, i8::MAX, -1, 10, 0, 9, -128, 127, 5, 5],
[0, 0, 0, 0, 0, 0, 0, 0, 0, i8::MIN],
[i8::MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
}
#[test]
fn calculate_check_digit_is_total_on_hostile_digits() {
for digits in hostile_fixtures() {
let actual = crate::calculate_check_digit(digits);
assert!(
(0..=10).contains(&actual),
"calculate_check_digit({digits:?}) = {actual} out of 0..=10"
);
}
}
#[test]
fn validate_check_digit_is_total_on_hostile_digits() {
for digits in hostile_fixtures() {
let _ = crate::validate_check_digit(digits);
}
}
#[test]
fn method_and_free_fn_agree_on_hostile_digits() {
for digits in hostile_fixtures() {
let n = NHSNumber::new(digits);
assert_eq!(
n.calculate_check_digit(),
crate::calculate_check_digit(digits)
);
assert_eq!(n.check_digit(), crate::check_digit(digits));
assert_eq!(
n.validate_check_digit(),
crate::validate_check_digit(digits)
);
}
}
#[test]
fn validate_rejects_hostile_stored_check_digit() {
let digits = [9, 9, 9, 1, 2, 3, 4, 5, 6, 10];
let actual = crate::validate_check_digit(digits);
let expect = true; assert_eq!(
actual, expect,
"stored out-of-range 10 currently matches the sentinel; \
see spec/18-open-questions-and-divergences.md §18.5 — reachable only via `new` or a struct \
literal now that Deserialize validates the digit range (R20)"
);
}
}
mod serialisation {
use super::super::*;
#[test]
fn serialize_json_shape_is_digits_array() {
let n = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
let actual = serde_json::to_string(&n).unwrap();
let expect = r#"{"digits":[9,9,9,1,0,0,0,0,0,3]}"#;
assert_eq!(actual, expect);
}
#[test]
fn deserialize_json_shape() {
let json = r#"{"digits":[9,9,9,1,0,0,0,0,0,3]}"#;
let actual: NHSNumber = serde_json::from_str(json).unwrap();
let expect = NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]);
assert_eq!(actual, expect);
}
#[test]
fn serde_round_trip() {
for digits in [[0; 10], [9; 10], [9, 4, 3, 4, 7, 6, 5, 9, 1, 9]] {
let n = NHSNumber::new(digits);
let json = serde_json::to_string(&n).unwrap();
let back: NHSNumber = serde_json::from_str(&json).unwrap();
assert_eq!(back, n);
}
}
#[test]
fn deserialize_rejects_wrong_arity() {
assert!(serde_json::from_str::<NHSNumber>(r#"{"digits":[9,9,9]}"#).is_err());
assert!(
serde_json::from_str::<NHSNumber>(r#"{"digits":[9,9,9,1,0,0,0,0,0,3,7]}"#).is_err()
);
assert!(serde_json::from_str::<NHSNumber>(r#"{"digits":[]}"#).is_err());
}
#[test]
fn deserialize_rejects_non_i8_values() {
assert!(
serde_json::from_str::<NHSNumber>(r#"{"digits":[200,9,9,1,0,0,0,0,0,3]}"#).is_err()
);
assert!(
serde_json::from_str::<NHSNumber>(r#"{"digits":[9.5,9,9,1,0,0,0,0,0,3]}"#).is_err()
);
assert!(
serde_json::from_str::<NHSNumber>(r#"{"digits":["9",9,9,1,0,0,0,0,0,3]}"#).is_err()
);
}
#[test]
fn deserialize_rejects_malformed_documents() {
assert!(serde_json::from_str::<NHSNumber>("").is_err());
assert!(serde_json::from_str::<NHSNumber>("null").is_err());
assert!(serde_json::from_str::<NHSNumber>(r#""999 100 0003""#).is_err());
assert!(
serde_json::from_str::<NHSNumber>(r#"{"digit":[0,0,0,0,0,0,0,0,0,0]}"#).is_err()
);
assert!(serde_json::from_str::<NHSNumber>(r#"{"digits":"9991000003"}"#).is_err());
}
#[test]
fn deserialize_rejects_out_of_range_digits() {
for bad in [
r#"{"digits":[-1,99,9,1,0,0,0,0,0,3]}"#,
r#"{"digits":[-1,0,0,0,0,0,0,0,0,0]}"#,
r#"{"digits":[0,0,0,0,0,0,0,0,0,10]}"#,
r#"{"digits":[127,0,0,0,0,0,0,0,0,0]}"#,
r#"{"digits":[0,0,0,0,-128,0,0,0,0,0]}"#,
] {
assert!(
serde_json::from_str::<NHSNumber>(bad).is_err(),
"{bad} must be rejected"
);
}
}
#[test]
fn deserialize_error_does_not_echo_payload() {
let err = serde_json::from_str::<NHSNumber>(r#"{"digits":[-1,99,9,1,0,0,0,0,0,3]}"#)
.unwrap_err();
let message = err.to_string();
assert!(!message.contains("99"), "no digit echo, got: {message}");
}
#[test]
fn deserialize_accepts_every_in_range_digit_value() {
for d in 0i8..=9 {
let json = std::format!(
r#"{{"digits":[{0},{0},{0},{0},{0},{0},{0},{0},{0},{0}]}}"#,
d
);
let n: NHSNumber = serde_json::from_str(&json).unwrap();
assert_eq!(n.digits, [d; 10]);
}
}
}
mod concurrency {
use super::super::*;
use std::str::FromStr;
use std::thread;
const THREADS: usize = 8;
const ITERATIONS: usize = 1_000;
#[test]
fn parallel_parse_format_validate_are_consistent() {
thread::scope(|scope| {
for _ in 0..THREADS {
scope.spawn(|| {
for _ in 0..ITERATIONS {
let n = NHSNumber::from_str("999 100 0003").unwrap();
assert!(n.validate_check_digit());
assert_eq!(n.to_string(), "999 100 0003");
let bad = NHSNumber::from_str("9991000004").unwrap();
assert!(!bad.validate_check_digit());
}
});
}
});
}
#[test]
fn parallel_first_touch_of_lazylock_statics() {
thread::scope(|scope| {
let handles: Vec<_> = (0..THREADS)
.map(|_| {
scope.spawn(|| {
let min = *crate::testable::TESTABLE_MIN;
let max = *crate::testable::TESTABLE_MAX;
let contains = crate::testable::TESTABLE_RANGE_INCLUSIVE
.contains(&NHSNumber::new([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]));
(min, max, contains)
})
})
.collect();
for handle in handles {
let (min, max, contains) = handle.join().unwrap();
assert_eq!(min.digits, [9, 9, 9, 0, 0, 0, 0, 0, 0, 0]);
assert_eq!(max.digits, [9; 10]);
assert!(contains);
}
});
}
#[test]
fn parallel_random_sampling_stays_in_range() {
thread::scope(|scope| {
for _ in 0..THREADS {
scope.spawn(|| {
for _ in 0..ITERATIONS {
let n = NHSNumber::testable_random_sample();
assert_eq!(&n.digits[0..3], &[9, 9, 9]);
assert!(crate::testable::TESTABLE_RANGE_INCLUSIVE.contains(&n));
}
});
}
});
}
#[test]
fn values_can_be_sent_across_threads() {
let n = NHSNumber::from_str("943 476 5919").unwrap();
let handle = thread::spawn(move || n.validate_check_digit());
assert!(handle.join().unwrap());
}
}
mod ranges {
use super::super::*;
fn digits_with_invalid_check(n9: u64) -> [i8; 10] {
let mut digits = [0i8; 10];
let mut rest = n9;
for i in (0..9).rev() {
digits[i] = (rest % 10) as i8;
rest /= 10;
}
assert_eq!(rest, 0, "n9 must have at most nine digits");
let calc = crate::calculate_check_digit(digits);
digits[9] = if calc == 10 { 0 } else { (calc + 1) % 10 };
assert!(
!crate::validate_check_digit(digits),
"fixture must have an invalid check digit (safety.md §1)"
);
digits
}
#[test]
fn boundaries_of_every_issued_and_reserved_range() {
let cases: [(u64, bool); 18] = [
(0, false), (10_099_999, false), (299_999_999, false), (300_000_000, false), (311_299_999, false), (311_300_000, true), (320_000_000, true), (320_000_001, false), (399_999_999, false), (400_000_000, true), (499_999_999, true), (500_000_000, false), (599_999_999, false), (600_000_000, true), (799_999_999, true), (800_000_000, false), (999_000_000, false), (999_999_999, false), ];
for (n9, expect) in cases {
let digits = digits_with_invalid_check(n9);
let actual = crate::is_issuable_range(digits);
assert_eq!(actual, expect, "n9 = {n9}");
}
}
#[test]
fn predicate_ignores_the_check_digit() {
for tenth in 0i8..=9 {
let mut digits = digits_with_invalid_check(400_000_000);
digits[9] = tenth;
assert!(crate::is_issuable_range(digits));
}
}
#[test]
fn method_and_free_fn_agree() {
for n9 in [0u64, 311_300_000, 400_000_000, 799_999_999, 999_999_999] {
let digits = digits_with_invalid_check(n9);
let n = NHSNumber::new(digits);
assert_eq!(n.is_issuable_range(), crate::is_issuable_range(digits));
}
}
#[test]
fn testable_fixtures_are_never_issuable() {
assert!(!crate::is_issuable_range([9, 9, 9, 1, 0, 0, 0, 0, 0, 3]));
assert!(!TESTABLE_MIN.is_issuable_range());
assert!(!TESTABLE_MAX.is_issuable_range());
for _ in 0..32 {
assert!(!NHSNumber::testable_random_sample().is_issuable_range());
}
}
#[test]
fn hostile_digits_are_never_issuable() {
assert!(!crate::is_issuable_range([i8::MIN; 10]));
assert!(!crate::is_issuable_range([i8::MAX; 10]));
assert!(!crate::is_issuable_range([-1, 0, 0, 0, 0, 0, 0, 0, 0, 0]));
assert!(crate::is_issuable_range([
4,
0,
0,
0,
0,
0,
0,
0,
0,
i8::MIN
]));
}
}
mod fuzz {
use super::super::*;
use proptest::prelude::*;
use std::str::FromStr;
fn in_domain_digits() -> impl Strategy<Value = [i8; 10]> {
proptest::array::uniform10(0i8..=9)
}
proptest! {
#[test]
fn check_digit_functions_are_total_on_any_i8(
digits in proptest::array::uniform10(any::<i8>())
) {
let c = crate::calculate_check_digit(digits);
prop_assert!((0..=10).contains(&c));
let _ = crate::validate_check_digit(digits);
let _ = crate::check_digit(digits);
}
#[test]
fn display_shape_holds_for_all_in_domain_values(digits in in_domain_digits()) {
let s = NHSNumber::new(digits).to_string();
let bytes = s.as_bytes();
prop_assert_eq!(bytes.len(), 12);
prop_assert!(s.is_ascii());
prop_assert_eq!(bytes[3], b' ');
prop_assert_eq!(bytes[7], b' ');
}
#[test]
fn round_trip_holds_for_all_in_domain_values(digits in in_domain_digits()) {
let n = NHSNumber::new(digits);
let via_canonical = NHSNumber::from_str(&n.to_string()).unwrap();
prop_assert_eq!(via_canonical, n);
let tight: String =
digits.iter().map(|d| (b'0' + *d as u8) as char).collect();
let via_tight = NHSNumber::from_str(&tight).unwrap();
prop_assert_eq!(via_tight, n);
}
#[test]
fn method_free_fn_equivalence_holds_for_all_in_domain_values(
digits in in_domain_digits()
) {
let n = NHSNumber::new(digits);
prop_assert_eq!(n.to_string(), crate::format(digits));
prop_assert_eq!(n.check_digit(), crate::check_digit(digits));
prop_assert_eq!(
n.calculate_check_digit(),
crate::calculate_check_digit(digits)
);
prop_assert_eq!(
n.validate_check_digit(),
crate::validate_check_digit(digits)
);
}
#[test]
fn validation_matches_definition(digits in in_domain_digits()) {
let calc = crate::calculate_check_digit(digits);
prop_assert_eq!(
crate::validate_check_digit(digits),
digits[9] == calc
);
for wrong in 0i8..=9 {
if wrong == calc { continue; }
let mut mutated = digits;
mutated[9] = wrong;
prop_assert!(!crate::validate_check_digit(mutated));
}
}
#[test]
fn single_digit_errors_are_detected(digits in in_domain_digits()) {
let calc = crate::calculate_check_digit(digits);
prop_assume!(calc <= 9); let mut valid = digits;
valid[9] = calc;
prop_assert!(crate::validate_check_digit(valid));
for pos in 0..9 {
for replacement in 0i8..=9 {
if replacement == valid[pos] { continue; }
let mut corrupted = valid;
corrupted[pos] = replacement;
prop_assert!(
!crate::validate_check_digit(corrupted),
"single-digit error at {} undetected", pos
);
}
}
}
#[test]
fn adjacent_transpositions_are_detected(digits in in_domain_digits()) {
let calc = crate::calculate_check_digit(digits);
prop_assume!(calc <= 9);
let mut valid = digits;
valid[9] = calc;
for pos in 0..8 {
if valid[pos] == valid[pos + 1] { continue; }
let mut swapped = valid;
swapped.swap(pos, pos + 1);
prop_assert!(
!crate::validate_check_digit(swapped),
"transposition at {} undetected", pos
);
}
}
#[test]
fn serde_round_trip_holds_for_all_in_domain_values(digits in in_domain_digits()) {
let n = NHSNumber::new(digits);
let json = serde_json::to_string(&n).unwrap();
let back: NHSNumber = serde_json::from_str(&json).unwrap();
prop_assert_eq!(back, n);
}
}
}
}