//! Tests for the wikilink scanner and its resolution against the session's //! known entities. use std::sync::{Arc, Mutex}; use super::{Resolution, ScanState, Scanner, extract_wikilinks, note_body, stub_body}; use crate::bus::{Flow, Listener, Speaker}; use crate::campaign::START; use tempfile::TempDir; /// The completed entity addresses, read while the lock guard is held so /// the borrow does not outlive the guard. fn completed_addresses(state: &Arc>) -> Vec { let state = state.lock().unwrap(); state .completed() .map(|entity| entity.address.clone()) .collect() } // --------------------------------------------------------------------------- // extraction // --------------------------------------------------------------------------- #[test] fn extract_finds_each_wikilinks_address() { // `[...]` are literal text to a markdown parser, so only the // double-bracket links count. assert_eq!( extract_wikilinks("You meet [[Vera Blackwater]]. She is at [[the Rusty Anchor|the inn]]."), vec![ "Vera Blackwater".to_string(), "the Rusty Anchor".to_string() ] ); } #[test] fn extract_ignores_reference_and_image_links() { assert_eq!( extract_wikilinks("A [plain](https://example.com) link and ![](img.png) and `code`."), Vec::::new() ); } #[test] fn extract_returns_nothing_for_plain_text() { assert_eq!( extract_wikilinks("Just narration, no links at all."), Vec::::new() ); } // --------------------------------------------------------------------------- // resolution // --------------------------------------------------------------------------- #[test] fn an_exact_address_resolves_ignore_case() { let mut state = ScanState::default(); state.register("Vera Blackwater"); assert_eq!( state.resolve("vera blackwater"), Resolution::Found("Vera Blackwater".to_string()) ); } #[test] fn a_scoped_address_resolves_exactly() { let mut state = ScanState::default(); state.register("people/Joseph Black"); assert_eq!( state.resolve("people/Joseph Black"), Resolution::Found("people/Joseph Black".to_string()) ); assert_eq!( state.resolve("people/joseph black"), Resolution::Found("people/Joseph Black".to_string()) ); } #[test] fn a_bare_name_matches_a_scoped_entity_by_its_name() { let mut state = ScanState::default(); state.register("people/Joseph Black"); assert_eq!( state.resolve("Joseph Black"), Resolution::Found("people/Joseph Black".to_string()) ); } #[test] fn a_bare_name_that_matches_several_is_ambiguous() { let mut state = ScanState::default(); state.register("people/Joseph Black"); state.register("guards/Joseph Black"); assert_eq!(state.resolve("Joseph Black"), Resolution::Ambiguous); } #[test] fn a_scoped_address_that_is_absent_is_none() { let mut state = ScanState::default(); state.register("people/Joseph Black"); assert_eq!(state.resolve("places/the-inn"), Resolution::None); } #[test] fn a_bare_name_that_matches_nothing_is_none() { let mut state = ScanState::default(); state.register("Vera Blackwater"); assert_eq!(state.resolve("the Rusty Anchor"), Resolution::None); } #[test] fn registering_the_same_address_twice_is_a_no_op() { let mut state = ScanState::default(); state.register("Vera Blackwater"); state.register("Vera Blackwater"); assert_eq!(state.completed().count(), 0); assert_eq!( state.resolve("Vera Blackwater"), Resolution::Found("Vera Blackwater".to_string()) ); } #[test] fn an_alias_resolves_to_its_entity() { let mut state = ScanState::default(); state.register_with_aliases("Vera Blackwater", vec!["the old assassin".to_string()]); assert_eq!( state.resolve("the old assassin"), Resolution::Found("Vera Blackwater".to_string()) ); } #[test] fn an_alias_resolves_ignoring_case() { let mut state = ScanState::default(); state.register_with_aliases("Vera Blackwater", vec!["The Innkeeper".to_string()]); assert_eq!( state.resolve("the innkeeper"), Resolution::Found("Vera Blackwater".to_string()) ); } #[test] fn an_alias_shared_by_two_entities_is_ambiguous() { let mut state = ScanState::default(); state.register_with_aliases("people/Vera Blackwater", vec!["the innkeeper".to_string()]); state.register_with_aliases("people/Marta Coalstack", vec!["the innkeeper".to_string()]); assert_eq!(state.resolve("the innkeeper"), Resolution::Ambiguous); } #[test] fn a_scoped_alias_resolves_a_retired_address() { // A retired scoped address, kept as an alias after a move, still // resolves, even though it looks like a scoped reference itself. let mut state = ScanState::default(); state.register_with_aliases( "people/Vera Blackwater", vec!["entities/Vera Blackwater".to_string()], ); assert_eq!( state.resolve("entities/Vera Blackwater"), Resolution::Found("people/Vera Blackwater".to_string()) ); } // --------------------------------------------------------------------------- // the scanner as a listener // --------------------------------------------------------------------------- #[test] fn the_scanner_accumulates_references_and_promotes_them_on_done() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); let mut scanner = Scanner::new(Arc::clone(&state), None); assert_eq!( scanner.delta("You see [[Vera Blackwater]] watching you."), Flow::Continue ); assert_eq!(scanner.delta(" She nods."), Flow::Continue); scanner.done("You see Vera Blackwater watching you. She nods."); assert_eq!( completed_addresses(&state), vec!["Vera Blackwater".to_string()] ); } #[test] fn a_new_name_is_auto_established_alongside_a_resolved_one() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); let mut scanner = Scanner::new(Arc::clone(&state), None); scanner.turn_start("I sleep.", Speaker::Player); scanner.delta("[[Vera]] appears. [[Vera Blackwater]] is here."); scanner.done("[Vera] appears. Vera Blackwater is here."); // `[[Vera]]` matches no entity named Vera, so it becomes a stub of its // own; `[[Vera Blackwater]]` is already known. Both are in context. assert_eq!( completed_addresses(&state), vec!["Vera".to_string(), "Vera Blackwater".to_string()] ); } #[test] fn a_turn_that_never_finishes_leaves_the_completed_set_alone() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); let mut scanner = Scanner::new(Arc::clone(&state), None); // A clean first turn promotes Vera. scanner.turn_start("I sleep.", Speaker::Player); scanner.delta("[[Vera Blackwater]] is here."); scanner.done("Vera Blackwater is here."); // A second turn starts, streams toward a reference, then is cancelled // before done: completed is untouched. scanner.turn_start("I search.", Speaker::Player); scanner.delta("[[Vera Blackwater]]"); assert_eq!( completed_addresses(&state), vec!["Vera Blackwater".to_string()] ); } #[test] fn a_turn_start_resets_the_accumulation_for_the_next_turn() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); let mut scanner = Scanner::new(Arc::clone(&state), None); scanner.turn_start("I sleep.", Speaker::Player); scanner.delta("[[Vera Blackwater]] is here."); // The next turn starts; the reference from the prior turn is gone from // accumulation, though nothing promoted it. scanner.turn_start("I wake.", Speaker::Player); scanner.done("You wake."); assert_eq!(state.lock().unwrap().completed().count(), 0); } #[test] fn a_wikilink_split_across_deltas_is_still_resolved() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); let mut scanner = Scanner::new(Arc::clone(&state), None); scanner.turn_start("I listen.", Speaker::Player); scanner.delta("She is [[Vera "); scanner.delta("Blackwater]]."); scanner.done("She is Vera Blackwater."); // Re-scanning the whole turn's narration, rather than just the latest // delta, catches a link the model split across two deltas. assert_eq!( completed_addresses(&state), vec!["Vera Blackwater".to_string()] ); } // --------------------------------------------------------------------------- // auto-establishment // --------------------------------------------------------------------------- #[test] fn an_unseen_bare_name_writes_a_stub_under_entities_and_lands_in_context() { let world = TempDir::new().unwrap(); let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); scanner.turn_start("I enter.", Speaker::Player); scanner.delta("You meet [[Vera Blackwater]], the innkeeper."); scanner.done("You meet Vera Blackwater, the innkeeper."); let stub = world.path().join("entities/Vera Blackwater.md"); let narration = "You meet [[Vera Blackwater]], the innkeeper."; assert_eq!( std::fs::read_to_string(&stub).unwrap(), stub_body("Vera Blackwater", START, narration) ); assert_eq!( completed_addresses(&state), vec!["Vera Blackwater".to_string()] ); } #[test] fn an_unseen_scoped_name_writes_a_stub_under_its_own_scope() { let world = TempDir::new().unwrap(); let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); scanner.turn_start("I enter.", Speaker::Player); scanner.delta("A guard steps forward: [[people/Joseph Black]]."); scanner.done("A guard steps forward: Joseph Black."); // The scoped stub lands under people/, not under entities/. let stub = world.path().join("people/Joseph Black.md"); let narration = "A guard steps forward: [[people/Joseph Black]]."; assert_eq!( std::fs::read_to_string(&stub).unwrap(), stub_body("people/Joseph Black", START, narration) ); assert!(!world.path().join("entities/Joseph Black.md").exists()); assert_eq!( completed_addresses(&state), vec!["people/Joseph Black".to_string()] ); } #[test] fn a_stub_already_on_disk_is_left_alone() { let world = TempDir::new().unwrap(); // A note from an earlier session is never clobbered back into a // fresh stub. let stub = world.path().join("entities/Vera Blackwater.md"); std::fs::create_dir_all(stub.parent().unwrap()).unwrap(); let before = "---\nkind: person\naliases: []\n---\n\nThe old assassin.\n"; std::fs::write(&stub, before).unwrap(); // A fresh session does not load disk stubs yet, so the name resolves // as unknown and auto-establishes, which must not overwrite the file. let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); scanner.turn_start("I enter.", Speaker::Player); scanner.delta("[[Vera Blackwater]] is here."); scanner.done("Vera Blackwater is here."); assert_eq!(std::fs::read_to_string(&stub).unwrap(), before); } #[test] fn a_directory_entity_gets_no_stub_beside_it() { let world = TempDir::new().unwrap(); // A character's sheet lives in a directory. The wikilink for it must // never establish a second, plain-file entity at the same address. let entry = world.path().join("characters/sister-maren/entry.md"); std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); std::fs::write(&entry, "---\nname: Sister Maren\n---\n\nA cleric.\n").unwrap(); let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); scanner.turn_start("I pray.", Speaker::Player); scanner.delta("[[characters/sister-maren]] lifts her holy symbol."); scanner.done("Sister Maren lifts her holy symbol."); assert!(!world.path().join("characters/sister-maren.md").exists()); assert_eq!( std::fs::read_to_string(&entry).unwrap(), "---\nname: Sister Maren\n---\n\nA cleric.\n" ); } #[test] fn an_ambiguous_bare_name_injects_nothing_and_writes_no_stub() { let world = TempDir::new().unwrap(); let state = Arc::new(Mutex::new(ScanState::default())); { let mut state = state.lock().unwrap(); state.register("people/Joseph Black"); state.register("guards/Joseph Black"); } let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); scanner.turn_start("I enter.", Speaker::Player); scanner.delta("[[Joseph Black]] walks in."); scanner.done("Joseph Black walks in."); // Nothing resolves, no stub is written, nothing is injected. assert_eq!(completed_addresses(&state), Vec::::new()); assert!(!world.path().join("entities/Joseph Black.md").exists()); } #[test] fn a_new_name_with_no_world_holds_in_memory_only() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), None); scanner.turn_start("I enter.", Speaker::Player); scanner.delta("[[the Rusty Anchor]] looms ahead."); scanner.done("the Rusty Anchor looms ahead."); assert_eq!( completed_addresses(&state), vec!["the Rusty Anchor".to_string()] ); } #[test] fn a_stub_is_written_at_done_not_at_delta() { let world = TempDir::new().unwrap(); let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); scanner.turn_start("I enter.", Speaker::Player); scanner.delta("You meet [[Vera Blackwater]]."); let stub = world.path().join("entities/Vera Blackwater.md"); assert!(!stub.exists()); scanner.done("You meet Vera Blackwater."); assert!(stub.exists()); } #[test] fn a_cancelled_turn_writes_no_stub() { let world = TempDir::new().unwrap(); let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); scanner.turn_start("I enter.", Speaker::Player); scanner.delta("You meet [[Vera Blackwater]]."); scanner.cancelled("You meet Vera Blackwater."); assert!(!world.path().join("entities/Vera Blackwater.md").exists()); // The address still resolves in memory, so a second reference this // session does not establish a duplicate. assert_eq!( state.lock().unwrap().resolve("Vera Blackwater"), Resolution::Found("Vera Blackwater".to_string()) ); } // --------------------------------------------------------------------------- // note_body // --------------------------------------------------------------------------- #[test] fn note_body_strips_frontmatter_and_trims_the_body() { let world = TempDir::new().unwrap(); let file = world.path().join("entities/Vera Blackwater.md"); std::fs::create_dir_all(file.parent().unwrap()).unwrap(); std::fs::write( &file, "---\nkind: person\naliases: []\n---\n\n Vera keeps the Rusty Anchor. \n", ) .unwrap(); assert_eq!( note_body(world.path(), "Vera Blackwater"), Some("Vera keeps the Rusty Anchor.".to_string()) ); } #[test] fn note_body_reads_a_file_with_no_frontmatter_whole() { let world = TempDir::new().unwrap(); let file = world.path().join("entities/Vera Blackwater.md"); std::fs::create_dir_all(file.parent().unwrap()).unwrap(); std::fs::write(&file, "Vera keeps the Rusty Anchor.\n").unwrap(); assert_eq!( note_body(world.path(), "Vera Blackwater"), Some("Vera keeps the Rusty Anchor.".to_string()) ); } #[test] fn note_body_reads_a_directory_entitys_entry_file() { let world = TempDir::new().unwrap(); let entry = world.path().join("characters/sister-maren/entry.md"); std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); std::fs::write(&entry, "---\nname: Sister Maren\n---\n\nA cleric.\n").unwrap(); assert_eq!( note_body(world.path(), "characters/sister-maren"), Some("A cleric.".to_string()) ); } #[test] fn note_body_is_none_for_a_missing_file() { let world = TempDir::new().unwrap(); assert_eq!(note_body(world.path(), "Vera Blackwater"), None); } #[test] fn note_body_is_none_for_a_note_with_no_body() { let world = TempDir::new().unwrap(); let file = world.path().join("entities/Vera Blackwater.md"); std::fs::create_dir_all(file.parent().unwrap()).unwrap(); std::fs::write(&file, "---\nkind: person\naliases: []\n---\n\n").unwrap(); assert_eq!(note_body(world.path(), "Vera Blackwater"), None); }