sokoban-elements 0.2.1

Parsing and writing Sokoban, Hexoban, Trioban and Octoban file formats.
Documentation
mod helpers;
mod reader;
mod writer;

pub use reader::SokReader;
pub use writer::SokWriter;

#[cfg(test)]
mod tests {
    use std::fs::File;
    use std::io::{BufReader, Cursor};
    use std::path::PathBuf;

    use indoc::indoc;
    use tempfile::NamedTempFile;

    use super::super::file_format::{FileFormatReader, FileFormatWriter};
    use super::super::{IoReadError, WriterSettings};
    use super::*;
    use crate::utilities::is_blank;
    use crate::{SokobanPlus, Tessellation};

    macro_rules! tests_data_dir {
        () => {
            concat!(env!("CARGO_MANIFEST_DIR"), "/src/io/tests_data/")
        };
    }

    #[test]
    fn it_reads_collection_from_file() -> Result<(), IoReadError> {
        let collection = {
            let data_dir = PathBuf::from(tests_data_dir!());
            let src_file_path = data_dir.join("Original_and_Extra.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(None, true).read(&mut file)?
        };

        assert_eq!(collection.title, "Original & Extra");
        assert_eq!(collection.author, "Thinking Rabbit");
        assert_eq!(collection.created_at, "2022-07-01  01:05:06");
        assert_eq!(collection.updated_at, "2022-07-02  09:48:22");
        assert_eq!(
            collection.notes,
            ["Originate from: original.slc", "Some collection notes",].join("\n")
        );

        assert_eq!(collection.puzzles.len(), 91);

        let puzzle = &collection.puzzles[0];
        assert_eq!(puzzle.title, "Level 1");
        assert_eq!(puzzle.author, "author name");
        assert_eq!(puzzle.notes, ["Time: 00:01:00", "", "Some puzzle notes"].join("\n"));

        assert!(puzzle.sokoban_plus.is_some());
        if let Some(sp) = &puzzle.sokoban_plus {
            assert_eq!(sp.boxorder(), "1 2 3 4 5 6");
            assert_eq!(sp.goalorder(), "6 5 4 3 2 1");
        }

        let board = indoc! {"    #####
                                 #   #
                                 #$  #
                               ###  $##
                               #  $ $ #
                             ### # ## #   ######
                             #   # ## #####  ..#
                             # $  $          ..#
                             ##### ### #@##  ..#
                                 #     #########
                                 #######"};

        assert_eq!(puzzle.board.printed(false), board);

        if puzzle.board.tessellation() == Tessellation::Sokoban {
            assert!(!puzzle.saved_games.is_empty());
            let saved_game = &puzzle.saved_games[0];

            assert_eq!(
                saved_game.notes,
                [
                    "Some save game notes",
                    "Optimizer: YASS 2.86",
                    "Time: 00:00:47",
                ]
                .join("\n")
            );

            assert_eq!(
                saved_game.printed_steps(),
                [
                    "ullluuuLUllDlldddrRRRRRRRRRRRRlllllllllllllulldRRRRRRRRRRRRRdrUlllllll",
                    "uuululldDDuulldddrRRRRRRRRRRuRRlDllllllluuulLulDDDuulldddrRRRRRRRRRRdR",
                    "RlUllllllluuulluuurDDluulDDDDDuulldddrRRRRRRRRRRRllllllluuuLLulDDDuull",
                    "dddrRRRRRRRRRRuRDldR"
                ]
                .join("")
            );

            assert_eq!(saved_game.solved_by, "only human");
        } else {
            return Err(IoReadError::from(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "PuzzleStorage should have at least one Puzzle after parsing",
            )));
        }

        Ok(())
    }

    #[test]
    fn it_loads_puzzle_without_title() -> Result<(), IoReadError> {
        let collection = {
            let src_file_path =
                PathBuf::from(tests_data_dir!()).join("parser_test_puzzle_no_title.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(None, true).read(&mut file)?
        };

        assert!(is_blank(&collection.puzzles[0].title));
        assert!(!is_blank(&collection.puzzles[0].board.printed(false)));

        Ok(())
    }

    #[test]
    fn it_loads_puzzle_with_multiple_saved_games() -> Result<(), IoReadError> {
        let collection = {
            let src_file_path =
                PathBuf::from(tests_data_dir!()).join("parser_test_multiple_saved_games.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(None, true).read(&mut file)?
        };

        if collection.puzzles[0].board.tessellation() == Tessellation::Sokoban {
            assert_eq!(collection.puzzles[0].saved_games.len(), 3);
            assert_eq!(
                collection.puzzles[0].saved_games[1].title,
                [
                    "This is note for first save game, but parser can't know that and should ",
                    "recognize it as heading of next save game."
                ]
                .join("")
            );
        }

        Ok(())
    }

    #[test]
    fn it_loads_last_saved_game_notes() -> Result<(), IoReadError> {
        let collection = {
            let src_file_path =
                PathBuf::from(tests_data_dir!()).join("parser_test_last_saved_game_notes.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(None, true).read(&mut file)?
        };

        assert_eq!(
            collection.puzzles[1].title,
            "This is note for last save game, not heading line"
        );
        assert_eq!(
            collection.puzzles[1].notes,
            "but parser can't know that and should recognize it as heading line"
        );

        Ok(())
    }

    #[test]
    fn it_correctly_loads_mixed_tessellations_collection() -> Result<(), IoReadError> {
        let collection = {
            let src_file_path = PathBuf::from(tests_data_dir!()).join("mixed_collection.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(None, true).read(&mut file)?
        };

        assert_eq!(
            collection
                .puzzles
                .iter()
                .filter(|p| { p.board.tessellation() == Tessellation::Sokoban })
                .count(),
            4
        );
        assert_eq!(
            collection
                .puzzles
                .iter()
                .filter(|p| { p.board.tessellation() == Tessellation::Hexoban })
                .count(),
            5
        );

        Ok(())
    }

    #[test]
    fn it_defaults_to_sokoban_if_not_specified() -> Result<(), IoReadError> {
        let collection = {
            let src_file_path =
                PathBuf::from(tests_data_dir!()).join("parser_test_variant_type_not_specified.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(None, true).read(&mut file)?
        };

        for puzzle in &collection.puzzles {
            assert!(puzzle.board.tessellation() == Tessellation::Sokoban);
        }

        Ok(())
    }

    #[test]
    fn it_uses_tessellation_value_from_collection_notes_even_if_hint_given()
    -> Result<(), IoReadError> {
        let collection = {
            let src_file_path = PathBuf::from(tests_data_dir!())
                .join("parser_test_variant_type_specified_global.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(Some(Tessellation::Octoban), true).read(&mut file)?
        };

        for puzzle in &collection.puzzles {
            assert!(puzzle.board.tessellation() == Tessellation::Trioban);
        }

        Ok(())
    }

    #[test]
    fn it_uses_tessellation_value_from_puzzle_notes_and_ignores_hint_and_collection_notes()
    -> Result<(), IoReadError> {
        let collection = {
            let src_file_path = PathBuf::from(tests_data_dir!())
                .join("parser_test_variant_type_specified_puzzle1.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(Some(Tessellation::Octoban), true).read(&mut file)?
        };

        assert!(collection.puzzles[0].board.tessellation() == Tessellation::Sokoban);
        assert!(collection.puzzles[1].board.tessellation() == Tessellation::Hexoban);

        Ok(())
    }

    #[test]
    fn it_uses_value_from_hint_when_nothing_is_specified_in_file() -> Result<(), IoReadError> {
        let collection = {
            let src_file_path = PathBuf::from(tests_data_dir!())
                .join("parser_test_variant_type_specified_puzzle2.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(Some(Tessellation::Trioban), true).read(&mut file)?
        };

        assert!(collection.puzzles[0].board.tessellation() == Tessellation::Trioban);
        assert!(collection.puzzles[1].board.tessellation() == Tessellation::Hexoban);

        Ok(())
    }

    #[test]
    fn it_writes_collection_to_file() -> Result<(), Box<dyn std::error::Error>> {
        let original = {
            let src_file_path = PathBuf::from(tests_data_dir!()).join("Original_and_Extra.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(None, true).read(&mut file)?
        };

        let saved = {
            // Create a file inside of `std::env::temp_dir()`.
            let mut file1 = NamedTempFile::new()?;
            // Re-open it.
            let mut file2 = BufReader::new(file1.reopen()?);
            // Write some test data to the first handle.
            SokWriter::new(None, false, WriterSettings::default())
                .write_collection(&original, &mut file1)?;
            // Read collect from second handle

            SokReader::new(None, true).read(&mut file2)?
        };

        assert_eq!(original.puzzles.len(), saved.puzzles.len());
        assert_eq!(original.puzzles, saved.puzzles);

        Ok(())
    }

    /// [`ReaderSettings::legacy_sokoban_plus`](crate::ReaderSettings) has to reach
    /// [`SokobanPlus::parsed_legacy()`](crate::SokobanPlus::parsed_legacy()) and nothing else. No
    /// fixture carries a `99`, so without this the whole flag is unguarded.
    #[test]
    fn it_reads_99_as_a_plus_id_unless_legacy_sokoban_plus_is_set()
    -> Result<(), Box<dyn std::error::Error>> {
        let data = indoc! {"
            #####
            #@$.#
            #####

            boxorder: 99 3
            goalorder: 3 99
        "};

        let collection = SokReader::new(None, false).read(&mut Cursor::new(data.as_bytes()))?;
        let sokoban_plus = collection.puzzles[0]
            .sokoban_plus
            .as_ref()
            .expect("Sokoban+");
        assert_eq!(sokoban_plus.boxorder(), "99 3");

        let collection = SokReader::new(None, true).read(&mut Cursor::new(data.as_bytes()))?;
        let sokoban_plus = collection.puzzles[0]
            .sokoban_plus
            .as_ref()
            .expect("Sokoban+");
        assert_eq!(sokoban_plus.boxorder(), "0 3");

        Ok(())
    }

    /// `boxorder` and `goalorder` are written into the file verbatim, so a collapsed interior gap
    /// renumbers boxes on the next read - and that read succeeds, so nothing signals it.
    #[test]
    fn it_writes_sokoban_plus_interior_gaps_to_file() -> Result<(), Box<dyn std::error::Error>> {
        let mut original = {
            let src_file_path = PathBuf::from(tests_data_dir!()).join("Original_and_Extra.sok");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SokReader::new(None, true).read(&mut file)?
        };
        original.puzzles[0].sokoban_plus = Some(SokobanPlus::parsed("1 0 0 2", "2 0 0 1")?);

        let mut file1 = NamedTempFile::new()?;
        let mut file2 = BufReader::new(file1.reopen()?);
        SokWriter::new(None, false, WriterSettings::default())
            .write_collection(&original, &mut file1)?;

        let written = std::fs::read_to_string(file1.path())?;
        assert!(written.contains("boxorder: 1 0 0 2"), "{written}");
        assert!(written.contains("goalorder: 2 0 0 1"), "{written}");

        let saved = SokReader::new(None, true).read(&mut file2)?;
        assert_eq!(original.puzzles, saved.puzzles);

        Ok(())
    }
}