sashite-sin 1.1.0

Style Identifier Notation (SIN): a compact, ASCII-only, no_std token encoding a player's side and style in abstract strategy board games.
Documentation
//! Transformation, query, and ordering tests.
//!
//! These exercise the algebra of the builder-style methods on [`Identifier`]
//! (each returns a new value; the type is `Copy`) over the whole closed domain,
//! plus the [`Letter`] helpers and the documented total orderings.
//!
//! They also pin the invariant the crate's only arithmetic depends on. Three
//! sites shift a byte by 32 to change case (`byte - 32`, `c as u8 - 32`,
//! `self.0 + 32`); each panics on overflow in a debug build. They are safe only
//! because a [`Letter`] always wraps `b'A'..=b'Z'`, an invariant upheld by
//! private construction — which no compiler check enforces, so a test must.

use core::cmp::Ordering;
use sashite_sin::{Identifier, Letter, ParseError, Side};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};

/// Builds all 52 identifiers directly from their typed components.
fn all_ids() -> Vec<Identifier> {
    let mut ids = Vec::with_capacity(52);
    for letter in Letter::ALL {
        for side in [Side::First, Side::Second] {
            ids.push(Identifier::new(letter, side));
        }
    }
    ids
}

#[test]
fn flip_is_an_involution_and_changes_only_side() {
    for id in all_ids() {
        let flipped = id.flipped();
        assert_ne!(flipped.side(), id.side());
        assert_eq!(flipped.flipped(), id, "double flip must restore {id:?}");
        // The letter (the only other attribute) is preserved.
        assert_eq!(flipped.letter(), id.letter());

        // Flipping toggles the case of the rendered character.
        let expected = if id.is_first() {
            id.to_char().to_ascii_lowercase()
        } else {
            id.to_char().to_ascii_uppercase()
        };
        assert_eq!(flipped.to_char(), expected);
    }
    // Side's own involution.
    assert_eq!(Side::First.flip().flip(), Side::First);
}

#[test]
fn with_setters_change_only_their_target() {
    let chinese = Letter::try_from_char('C').unwrap();

    for id in all_ids() {
        for side in [Side::First, Side::Second] {
            let changed = id.with_side(side);
            assert_eq!(changed.side(), side);
            assert_eq!(changed.letter(), id.letter());
        }

        let changed = id.with_letter(chinese);
        assert_eq!(changed.letter(), chinese);
        assert_eq!(changed.side(), id.side());
    }
}

#[test]
fn queries_agree_with_accessors() {
    for id in all_ids() {
        assert_eq!(id.is_first(), id.side() == Side::First);
        assert_eq!(id.is_second(), id.side() == Side::Second);

        // Exactly one side-query holds.
        assert!(id.is_first() ^ id.is_second());
    }
}

// The const fns are usable in const context: these are evaluated at compile time.
const WESTERN: Identifier = Identifier::new(Letter::ALL[22], Side::First); // 'W'
const WESTERN_SECOND: Identifier = WESTERN.flipped();
const CHINESE_FIRST: Identifier = WESTERN.with_letter(Letter::ALL[2]); // 'C'

#[test]
fn transforms_work_in_const_context() {
    assert_eq!(WESTERN.letter().as_char(), 'W');
    assert_eq!(WESTERN.encode().as_str(), "W");
    assert_eq!(WESTERN_SECOND.encode().as_str(), "w");
    assert_eq!(CHINESE_FIRST.encode().as_str(), "C");
}

// Parsing and validation are const fns too: usable at compile time.
const PARSED: Result<Identifier, ParseError> = Identifier::parse("W");
const WESTERN_IS_VALID: bool = Identifier::is_valid("W");

#[test]
fn parsing_works_in_const_context() {
    // Each const is evaluated at compile time; comparing against a runtime call
    // both consumes it and confirms const and runtime parsing agree.
    assert_eq!(WESTERN_IS_VALID, Identifier::is_valid("W"));
    assert_eq!(PARSED, Identifier::parse("W"));
}

#[test]
fn letter_helpers() {
    // Case folding: both cases yield the same uppercase letter.
    let upper = Letter::try_from_char('C').unwrap();
    let lower = Letter::try_from_char('c').unwrap();
    assert_eq!(upper, lower);
    assert_eq!(upper.as_char(), 'C');
    assert_eq!(upper.as_ascii(), b'C');

    // TryFrom<char>.
    assert_eq!(Letter::try_from('Z').unwrap().as_char(), 'Z');
    assert!(Letter::try_from('!').is_err());

    // from_ascii reports the side implied by case.
    let letter_a = Letter::try_from_char('A').unwrap();
    assert_eq!(Letter::from_ascii(b'A'), Some((letter_a, Side::First)));
    assert_eq!(
        Letter::from_ascii(b'a').map(|(letter, side)| (letter.as_char(), side)),
        Some(('A', Side::Second)),
    );
    assert_eq!(Letter::from_ascii(b'0'), None);

    // ALL spans A..=Z in order.
    let spelled: String = Letter::ALL.iter().map(|letter| letter.as_char()).collect();
    assert_eq!(spelled, "ABCDEFGHIJKLMNOPQRSTUVWXYZ");
}

#[test]
fn derived_orderings_are_canonical() {
    assert!(Side::First < Side::Second);

    // Identifier compares by letter, then side.
    let less = |left: &str, right: &str| {
        Identifier::parse(left).unwrap() < Identifier::parse(right).unwrap()
    };
    assert!(less("A", "B")); // letter
    assert!(less("A", "a")); // same letter: First < Second
    assert!(!less("Z", "a")); // 'Z' > 'A': the letter dominates over side
    assert!(less("a", "B")); // letter 'A' < 'B' regardless of side

    // The documented order is letter-then-side, which is *not* the order of
    // the rendered characters: `a` sorts after `Z` in ASCII but before it here.
    let a_second = Identifier::parse("a").unwrap();
    let z_first = Identifier::parse("Z").unwrap();
    assert_eq!(a_second.cmp(&z_first), Ordering::Less);
    assert_eq!(
        a_second.to_char().cmp(&z_first.to_char()),
        Ordering::Greater
    );
}

/// The invariant every `Letter` must satisfy: the byte it wraps is an uppercase
/// ASCII letter.
fn assert_letter_invariant(letter: Letter, origin: &str) {
    let byte = letter.as_ascii();
    assert!(
        byte.is_ascii_uppercase(),
        "Letter invariant broken from {origin}: byte {byte:#04X}",
    );
    assert_eq!(letter.as_char(), char::from(byte));

    // `to_ascii` adds 32 for the second side. The invariant bounds the byte at
    // `b'Z'` (90), so the sum is at most 122 and cannot overflow a `u8`; going
    // through the public path proves the result is the matching lowercase byte
    // rather than a wrapped one.
    let lowered = Identifier::new(letter, Side::Second).to_char();
    assert_eq!(u32::from(byte) + 32, u32::from(lowered));
    assert!(lowered.is_ascii_lowercase());
    assert_eq!(
        Identifier::new(letter, Side::First).to_char(),
        letter.as_char()
    );
}

#[test]
fn every_public_constructor_upholds_the_letter_invariant() {
    // `Letter::ALL`.
    for letter in Letter::ALL {
        assert_letter_invariant(letter, "ALL");
    }

    // `from_ascii`, over every byte. This is the site of `byte - 32`: the
    // subtraction is reached only from the `b'a'..=b'z'` arm, where the byte is
    // at least 97, so it cannot underflow.
    for byte in 0u8..=255 {
        match Letter::from_ascii(byte) {
            Some((letter, side)) => {
                assert!(byte.is_ascii_alphabetic(), "byte {byte:#04X} decoded");
                assert_letter_invariant(letter, "from_ascii");
                // Case decides the side, and nothing else does.
                let expected = if byte.is_ascii_uppercase() {
                    Side::First
                } else {
                    Side::Second
                };
                assert_eq!(side, expected, "byte {byte:#04X}");
                // Decoding is lossless: the byte is recoverable.
                assert_eq!(Identifier::new(letter, side).to_char(), char::from(byte));
            }
            None => assert!(!byte.is_ascii_alphabetic(), "byte {byte:#04X} rejected"),
        }
    }

    // `try_from_char` and `TryFrom<char>`, over the whole Unicode code space.
    // This is the site of `c as u8 - 32`: the cast is reached only from the
    // `'a'..='z'` arm, so it neither truncates nor underflows. Sweeping every
    // scalar also rules out a case-folding surprise — `ſ` (U+017F) and `K`
    // (U+212A) uppercase to ASCII letters yet must be rejected.
    let mut accepted = 0u32;
    for scalar in 0u32..=0x0010_FFFF {
        let Some(c) = char::from_u32(scalar) else {
            continue;
        };
        assert_eq!(
            Letter::try_from(c),
            Letter::try_from_char(c),
            "U+{scalar:04X}"
        );
        match Letter::try_from_char(c) {
            Ok(letter) => {
                assert!(c.is_ascii_alphabetic(), "char U+{scalar:04X} accepted");
                assert_letter_invariant(letter, "try_from_char");
                assert_eq!(letter.as_char(), c.to_ascii_uppercase());
                accepted += 1;
            }
            Err(e) => {
                assert!(!c.is_ascii_alphabetic(), "char U+{scalar:04X} rejected");
                assert_eq!(e, ParseError::InvalidLetter, "U+{scalar:04X}");
            }
        }
    }
    assert_eq!(
        accepted, 52,
        "exactly the 52 ASCII letters are abbreviations"
    );

    // The parser, and the accessor that hands a `Letter` back out.
    for byte in 0u8..=255 {
        let buf = [byte];
        if let Ok(id) = Identifier::try_from(&buf[..]) {
            assert_letter_invariant(id.letter(), "Identifier::try_from(&[u8])");
        }
    }
}

#[test]
fn encoding_round_trips_over_the_whole_product() {
    for id in all_ids() {
        let encoded = id.encode();

        // `parse ∘ encode = id`.
        assert_eq!(Identifier::parse(&encoded).unwrap(), id);
        assert_eq!(
            Identifier::try_from(encoded.as_str().as_bytes()).unwrap(),
            id
        );

        // `encode ∘ parse = id` on valid input.
        let text = encoded.as_str().to_owned();
        assert_eq!(Identifier::parse(&text).unwrap().encode().as_str(), text);

        // The four renderings of a token are one and the same character.
        assert_eq!(id.to_char().to_string(), text);
        assert_eq!(id.to_string(), text);
        assert_eq!(format!("{id}"), text);
        assert_eq!(encoded.as_str(), text);

        // Case carries the side, and only the side.
        assert_eq!(id.to_char().is_ascii_uppercase(), id.is_first());
        assert_eq!(id.to_char().to_ascii_uppercase(), id.letter().as_char());
    }
}

#[test]
fn setters_compose_over_the_whole_product() {
    for id in all_ids() {
        // Setting an attribute to what it already is changes nothing.
        assert_eq!(id.with_letter(id.letter()), id);
        assert_eq!(id.with_side(id.side()), id);

        for letter in Letter::ALL {
            for side in [Side::First, Side::Second] {
                // Setting both, in either order, reaches `new` exactly.
                let target = Identifier::new(letter, side);
                assert_eq!(id.with_letter(letter).with_side(side), target);
                assert_eq!(id.with_side(side).with_letter(letter), target);

                // A setter is idempotent and overwrites the previous value.
                assert_eq!(
                    id.with_letter(letter).with_letter(letter),
                    id.with_letter(letter)
                );
                assert_eq!(id.with_side(side).with_side(side), id.with_side(side));
            }
        }

        // `flipped` is exactly `with_side(side().flip())`.
        assert_eq!(id.flipped(), id.with_side(id.side().flip()));
    }
}

fn hash_of(id: Identifier) -> u64 {
    let mut hasher = DefaultHasher::new();
    id.hash(&mut hasher);
    hasher.finish()
}

#[test]
fn ord_partial_ord_eq_and_hash_are_mutually_consistent() {
    let ids = all_ids();

    for &left in &ids {
        for &right in &ids {
            // The derived order is exactly the order of the field tuple, which
            // is what "letter, then side" means concretely.
            let by_fields = (left.letter(), left.side()).cmp(&(right.letter(), right.side()));
            assert_eq!(left.cmp(&right), by_fields, "{left:?} vs {right:?}");

            // `PartialOrd` agrees with `Ord`, and both agree with `Eq`.
            assert_eq!(left.partial_cmp(&right), Some(left.cmp(&right)));
            assert_eq!(left == right, left.cmp(&right) == Ordering::Equal);
            assert_eq!(left < right, left.cmp(&right) == Ordering::Less);

            // Antisymmetry, and the `Hash`/`Eq` contract.
            assert_eq!(left.cmp(&right), right.cmp(&left).reverse());
            if left == right {
                assert_eq!(hash_of(left), hash_of(right));
            }
        }
    }

    // Transitivity over the closed domain.
    for &a in &ids {
        for &b in &ids {
            for &c in &ids {
                if a <= b && b <= c {
                    assert!(a <= c, "{a:?} <= {b:?} <= {c:?}");
                }
            }
        }
    }

    // `Letter`'s own order is alphabetical, which is what `Identifier` leans on.
    for (i, left) in Letter::ALL.iter().enumerate() {
        for (j, right) in Letter::ALL.iter().enumerate() {
            assert_eq!(left.cmp(right), i.cmp(&j));
        }
    }
}