sashite-feen 0.2.0

Field Expression Encoding Notation (FEEN): a compact, ASCII-only, no_std, zero-allocation validator and encoder for board-game positions in abstract strategy games, built on EPIN and SIN.
Documentation
//! Integration tests for full-position parsing through the public API.
//!
//! Each valid case is summarized as a tuple
//! `(dimension sizes, square count, piece count, side to move, active style)`
//! reconstructed from the public accessors, so the assertions exercise the
//! observable surface of [`Feen`] rather than any internal helper.

use sashite_feen::{Feen, ParseError, Side};

fn summary(s: &str) -> Result<(Vec<u8>, u32, u32, Side, char), ParseError> {
    let f = Feen::parse(s)?;
    Ok((
        f.shape().dimensions().to_vec(),
        f.square_count(),
        f.piece_count(),
        f.active_side(),
        f.active_style().to_char(),
    ))
}

fn err(s: &str) -> ParseError {
    Feen::parse(s).unwrap_err()
}

// ---------- valid full positions ----------

#[test]
fn chess_start() {
    let s = "-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / W/w";
    assert_eq!(summary(s).unwrap(), (vec![8, 8], 64, 32, Side::First, 'W'));
}

#[test]
fn shogi_start() {
    let s = "lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / J/j";
    assert_eq!(summary(s).unwrap(), (vec![9, 9], 81, 40, Side::First, 'J'));
}

#[test]
fn xiangqi_start() {
    let s = "rheag^aehr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RHEAG^AEHR / C/c";
    assert_eq!(summary(s).unwrap(), (vec![10, 9], 90, 32, Side::First, 'C'));
}

#[test]
fn empty_board() {
    let s = "8/8/8/8/8/8/8/8 / W/w";
    assert_eq!(summary(s).unwrap(), (vec![8, 8], 64, 0, Side::First, 'W'));
}

#[test]
fn with_hands() {
    let s = "8/8/8/8/8/8/8/8 3P2B/3p2b W/w";
    assert_eq!(summary(s).unwrap(), (vec![8, 8], 64, 10, Side::First, 'W'));
}

#[test]
fn one_dimensional() {
    let s = "k^+p4+PK^ / C/c";
    assert_eq!(summary(s).unwrap(), (vec![8], 8, 4, Side::First, 'C'));
}

#[test]
fn second_to_move() {
    let s = "8/8/8/8/8/8/8/8 / w/W";
    assert_eq!(summary(s).unwrap(), (vec![8, 8], 64, 0, Side::Second, 'w'));
}

// ---------- cardinality ----------

#[test]
fn too_many_pieces() {
    let s = "K^k^ 2K^/2k^ J/j"; // n = 2, p = 2 + 4 = 6
    assert_eq!(err(s), ParseError::TooManyPieces);
}

#[test]
fn exactly_full_board_is_ok() {
    let s = "k^ / J/j"; // 1 square, 1 piece on board, empty hands
    assert_eq!(summary(s).unwrap(), (vec![1], 1, 1, Side::First, 'J'));
}

// ---------- field-count / whitespace structure ----------

#[test]
fn empty_input() {
    assert_eq!(err(""), ParseError::FieldCount);
}

#[test]
fn two_fields() {
    assert_eq!(err("8/8 /"), ParseError::FieldCount);
}

#[test]
fn four_fields() {
    assert_eq!(err("8 / W/w extra"), ParseError::FieldCount);
}

#[test]
fn leading_space() {
    assert_eq!(err(" 8/8/8/8/8/8/8/8 / W/w"), ParseError::FieldCount);
}

#[test]
fn trailing_space() {
    assert_eq!(err("8/8/8/8/8/8/8/8 / W/w "), ParseError::FieldCount);
}

#[test]
fn double_space() {
    assert_eq!(err("8/8/8/8/8/8/8/8  / W/w"), ParseError::FieldCount);
}

// ---------- bounds ----------

#[test]
fn non_ascii() {
    assert_eq!(err("8/8/8/8/8/8/8/8 / W/é"), ParseError::NonAscii);
}

#[test]
fn too_long() {
    let big = "1".repeat(5000);
    let s = format!("{big} / W/w");
    assert_eq!(err(&s), ParseError::InputTooLong);
}

// ---------- per-field error propagation ----------

#[test]
fn placement_error_propagates() {
    assert_eq!(err("rkr//PPPP / W/w"), ParseError::DimensionalCoherence);
}

#[test]
fn hands_error_propagates() {
    assert_eq!(err("1 PP/ W/w"), ParseError::HandNotAggregated);
}

#[test]
fn style_error_propagates() {
    assert_eq!(err("1 / W/W"), ParseError::StylesSameCase);
}

#[test]
fn newline_in_field_rejected() {
    // a trailing newline lands inside field 3 and fails SIN parsing
    assert_eq!(
        err("8/8/8/8/8/8/8/8 / W/w\n"),
        ParseError::InvalidStyleToken
    );
}

// ---------- an empty field reports its own reason ----------
//
// `" / C/c"` really does have three space-separated fields, so `FieldCount`
// ("must have exactly three space-separated fields") would be a false statement
// about it. Each field's validator answers for itself instead.

#[test]
fn empty_placement_field() {
    assert_eq!(err(" / W/w"), ParseError::PlacementEmpty);
}

#[test]
fn empty_hands_field() {
    assert_eq!(err("8/8  W/w"), ParseError::InvalidHandsDelimiter);
}

#[test]
fn empty_style_turn_field() {
    assert_eq!(err("8/8 / "), ParseError::InvalidStyleTurnDelimiter);
}

// ---------- exact boundaries ----------
//
// Every bound below is asserted at the last accepted value *and* the first
// rejected one. The pre-existing tests all sat comfortably past the edge —
// `too_long` used ~5 000 bytes, `too_many_pieces` used p = 6 against n = 2 —
// so an off-by-one in any of these comparisons passed the whole suite. Two of
// them then make `Feen::to_qi` panic, which is precisely the invariant its
// `# Panics` section promises can never fire.

#[test]
fn input_length_boundary() {
    // 4 090 digits + " / W/w" is exactly MAX_STRING_LENGTH bytes. It is invalid
    // (one absurd empty-count), but the point is *which* error: the length gate
    // must let it through.
    let at_cap = format!("{} / W/w", "1".repeat(4090));
    assert_eq!(at_cap.len(), sashite_feen::MAX_STRING_LENGTH);
    assert_eq!(err(&at_cap), ParseError::DimensionTooLarge);

    // One byte more and nothing is parsed at all.
    let over_cap = format!("{} / W/w", "1".repeat(4091));
    assert_eq!(over_cap.len(), sashite_feen::MAX_STRING_LENGTH + 1);
    assert_eq!(err(&over_cap), ParseError::InputTooLong);

    // And a genuinely valid position of exactly MAX_STRING_LENGTH bytes parses:
    // 255 ranks of 15 cells, one of them spelled with wider tokens to land on
    // the cap exactly.
    let wide = format!("{}{}", "+K^'+K^'+K^'+K^", "K".repeat(11)); // 15 cells, 26 bytes
    let mut ranks = vec!["K".repeat(15); 254];
    ranks.push(wide);
    let at_cap_valid = format!("{} / W/w", ranks.join("/"));
    assert_eq!(at_cap_valid.len(), sashite_feen::MAX_STRING_LENGTH);
    let f = Feen::parse(&at_cap_valid).expect("exactly at the cap");
    assert_eq!(f.square_count(), 255 * 15);
    assert_eq!(f.piece_count(), 255 * 15);
}

#[test]
fn square_count_boundary() {
    // 255 x 255 = 65_025 squares: exactly MAX_SQUARE_COUNT, accepted.
    let at_cap = format!("{} / W/w", vec!["255"; 255].join("/"));
    assert_eq!(
        Feen::parse(&at_cap).expect("at the cap").square_count(),
        sashite_feen::MAX_SQUARE_COUNT
    );

    // 26 x 41 x 61 = 65_026 squares: one over, and small enough to encode in
    // ~3.2 KB so the byte cap cannot be the thing rejecting it.
    let layer = vec!["61"; 41].join("/");
    let over_cap = format!("{} / W/w", vec![layer; 26].join("//"));
    assert!(over_cap.len() < sashite_feen::MAX_STRING_LENGTH);
    assert_eq!(err(&over_cap), ParseError::TooManySquares);
}

#[test]
fn piece_cardinality_boundary() {
    // p == n is legal; p == n + 1 is not. One square, one piece:
    assert!(Feen::is_valid("K^ / J/j"));
    assert_eq!(err("K^ K/ J/j"), ParseError::TooManyPieces);

    // Same edge reached through the hands alone, on a two-square board.
    assert!(Feen::is_valid("2 K/k J/j"));
    assert_eq!(err("2 2K/k J/j"), ParseError::TooManyPieces);
}

/// `EmptySegment` documents itself as a defensive guard that no input reaches.
/// This asserts the claim rather than leaving it as a comment.
#[test]
fn the_defensive_empty_segment_variant_is_unreachable() {
    let alphabet = *b"K/ 10Cc+";
    let mut buffer = Vec::new();
    let mut checked = 0u64;
    for length in 0..=5usize {
        let total = alphabet.len().pow(u32::try_from(length).expect("small"));
        for n in 0..total {
            buffer.clear();
            let mut rest = n;
            for _ in 0..length {
                buffer.push(alphabet[rest % alphabet.len()]);
                rest /= alphabet.len();
            }
            let s = core::str::from_utf8(&buffer).expect("ASCII alphabet");
            assert_ne!(
                Feen::parse(s).err(),
                Some(ParseError::EmptySegment),
                "reached the defensive variant with {s:?}"
            );
            checked += 1;
        }
    }
    assert_eq!(checked, (0..=5).map(|k| 8u64.pow(k)).sum::<u64>());
}