diff --git a/crates/mind-body/src/address.rs b/crates/mind-body/src/address.rs index e97d91e..7b504d5 100644 --- a/crates/mind-body/src/address.rs +++ b/crates/mind-body/src/address.rs @@ -1,8 +1,8 @@ //! Whether a chat delivery is for a Mind: the one rule the heartbeat's off-beat wake and beat score and the Persona's `to_you` and //! stagger rank all call. A delivery addresses a Mind when its `m.mentions` holds her Matrix id (a tag), or its text holds one of her //! names as a whole word. Her names are her own (handle, public name; case does not matter) and her projects': the names her -//! stewardship records list, matched exactly as written, or, when a record lists none, the repo's name and its split form, matched in -//! any case. A deterministic identity match on names, not a reading of meaning. +//! stewardship records list, matched exactly as written, or, when a record lists none, the repo's name matched exactly as the repo +//! spells it and, for a multi-word name, its split form matched in any case. A deterministic identity match on names, not a reading of meaning. use eureka_pipeline::{OrgRepo, Short}; @@ -52,8 +52,9 @@ pub(crate) fn addresses(who: &Addressee, delivery: &ChatDelivery) -> bool { } /// The chat names of one stewarded repo. Listed names are exactly those, each matched exactly as the steward wrote it (capitalization -/// tells a project from a common word). With none listed: the repo's name after the org, and that name split at `-`, `_`, `.` and where a -/// lower-case letter or digit meets a capital, when the split reads differently; both match in any case. +/// tells a project from a common word). With none listed: the repo's name after the org, matched exactly as the repo spells it +/// (`Thing`, not `thing`), and that name split at `-`, `_`, `.` and where a lower-case letter or digit meets a capital, when the split +/// reads differently, matched in any case (a split phrase is not a common word). pub(crate) fn project_names(repo: &OrgRepo, listed: &[Short]) -> Vec { if !listed.is_empty() { return listed.iter().map(|name| Name { text: name.0.clone(), exact: true }).collect(); @@ -72,9 +73,9 @@ pub(crate) fn project_names(repo: &OrgRepo, listed: &[Short]) -> Vec { } previous = Some(ch); } - let (whole, split) = (whole.to_lowercase(), split.to_lowercase()); - let mut names = vec![Name { text: whole.clone(), exact: false }]; - if split != whole { + let split = split.to_lowercase(); + let mut names = vec![Name { text: whole.to_string(), exact: true }]; + if split != whole.to_lowercase() { names.push(Name { text: split, exact: false }); } names @@ -87,11 +88,18 @@ pub(crate) fn names_whole_word(text: &str, name: &str, exact: bool) -> bool { if name.is_empty() { return false; } - text.match_indices(&name).any(|(start, _)| { + let mut from = 0; + while let Some(found) = text[from..].find(&name) { + let start = from + found; let before = text[..start].chars().next_back(); let after = text[start + name.len()..].chars().next(); - !before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric) - }) + if !before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric) { + return true; + } + // Step one character, not one name: matches may overlap ("ab ab" in "xab ab ab"). + from = start + text[start..].chars().next().map_or(1, char::len_utf8); + } + false } #[cfg(test)] diff --git a/crates/mind-body/src/address/tests.rs b/crates/mind-body/src/address/tests.rs index ecd38d9..0159686 100644 --- a/crates/mind-body/src/address/tests.rs +++ b/crates/mind-body/src/address/tests.rs @@ -1,4 +1,4 @@ -//! The one addressing rule: a tag, or a name as a whole word; a listed name exactly as written, a derived or a Mind's own in any case. +//! The one addressing rule: a tag, or a name as a whole word; a listed name exactly as written, a derived one-word repo name as the repo spells it, a split phrase or a Mind's own in any case. use super::*; @@ -42,28 +42,42 @@ fn a_tag_her_name_or_one_of_her_project_names_as_a_whole_word_addresses_her_and_ #[test] fn project_names_derives_the_repo_name_and_its_split_form_and_listed_names_replace_them() { let derived = |repo: &str| project_names(&OrgRepo(repo.into()), &[]).into_iter().map(|name| (name.text, name.exact)).collect::>(); - let folded = |texts: &[&str]| texts.iter().map(|text| ((*text).to_string(), false)).collect::>(); - assert_eq!(derived("GameCult/BadDaySimulator"), folded(&["baddaysimulator", "bad day simulator"])); - assert_eq!(derived("GameCult/moonlight-ios"), folded(&["moonlight-ios", "moonlight ios"])); - assert_eq!(derived("GameCult/Huginn"), folded(&["huginn"])); - assert_eq!(derived("GameCult/snake_case.rs"), folded(&["snake_case.rs", "snake case rs"])); - assert_eq!(derived("GameCult/Vili2D"), folded(&["vili2d", "vili2 d"]), "a digit before a capital splits"); - assert_eq!(derived("GameCult/Vili2Dev"), folded(&["vili2dev", "vili2 dev"])); + let spelled = |whole: &str, split: &[&str]| { + let mut names = vec![(whole.to_string(), true)]; + names.extend(split.iter().map(|text| ((*text).to_string(), false))); + names + }; + assert_eq!(derived("GameCult/BadDaySimulator"), spelled("BadDaySimulator", &["bad day simulator"])); + assert_eq!(derived("GameCult/moonlight-ios"), spelled("moonlight-ios", &["moonlight ios"])); + assert_eq!(derived("GameCult/Huginn"), spelled("Huginn", &[])); + assert_eq!(derived("GameCult/Thing"), spelled("Thing", &[])); + assert_eq!(derived("GameCult/snake_case.rs"), spelled("snake_case.rs", &["snake case rs"])); + assert_eq!(derived("GameCult/Vili2D"), spelled("Vili2D", &["vili2 d"]), "a digit before a capital splits"); + assert_eq!(derived("GameCult/Vili2Dev"), spelled("Vili2Dev", &["vili2 dev"])); let listed = project_names(&OrgRepo("GameCult/BadDaySimulator".into()), &names(&["BDS", "Bad Day Simulator"])); assert_eq!(listed.into_iter().map(|name| (name.text, name.exact)).collect::>(), [("BDS".to_string(), true), ("Bad Day Simulator".to_string(), true)]); } #[test] -fn a_listed_name_matches_exactly_as_written_and_a_derived_name_in_any_case() { +fn a_listed_name_matches_exactly_as_written_a_derived_one_word_name_as_spelled_and_a_split_phrase_in_any_case() { let libby = Addressee::new("@libby:chat", ["Libby"]).with_projects(project_names(&OrgRepo("GameCult/Thing".into()), &names(&["Thing"]))); assert!(addresses(&libby, &said("Thing is down", &[]))); assert!(!addresses(&libby, &said("one thing at a time", &[]))); assert!(!addresses(&libby, &said("THING", &[]))); + let derived_thing = steward("GameCult/Thing", &[]); + assert!(addresses(&derived_thing, &said("Thing is down", &[]))); + for text in ["one thing at a time", "THING", "a thing"] { + assert!(!addresses(&derived_thing, &said(text, &[])), "{text}"); + } let nibu = steward("GameCult/BadDaySimulator", &[]); - for text in ["BADDAYSIMULATOR is red", "Bad Day Simulator is red", "bad day simulator is red", "is BadDaySimulator red"] { + for text in ["BadDaySimulator is red", "BAD DAY SIMULATOR is red", "Bad Day Simulator is red", "bad day simulator is red"] { assert!(addresses(&nibu, &said(text, &[])), "{text}"); } - assert!(!addresses(&nibu, &said("a bad day, simulated", &[]))); + for text in ["baddaysimulator is red", "BADDAYSIMULATOR is red", "a bad day, simulated"] { + assert!(!addresses(&nibu, &said(text, &[])), "{text}"); + } + let hyphen = steward("GameCult/moonlight-ios", &[]); + assert!(addresses(&hyphen, &said("moonlight-ios", &[])) && addresses(&hyphen, &said("Moonlight iOS", &[])) && !addresses(&hyphen, &said("Moonlight-iOS", &[]))); } #[test] @@ -75,8 +89,17 @@ fn one_name_addresses_every_mind_that_lists_it_each_on_her_own_record() { assert!(addresses(&lore, &both)); let two_stewards = [steward("GameCult/Thing", &["Thing"]), Addressee::new("@libby:chat", ["Libby"]).with_projects(project_names(&OrgRepo("GameCult/Thing".into()), &[]))]; assert!(two_stewards.iter().all(|who| addresses(who, &said("Thing is broken", &[]))), "a project with two stewards addresses each"); - assert!(!addresses(&two_stewards[0], &said("a thing", &[])), "each by her own record: listed exact, derived in any case"); - assert!(addresses(&two_stewards[1], &said("a thing", &[]))); + assert!(!addresses(&two_stewards[0], &said("a thing", &[])), "listed Thing is exact"); + assert!(!addresses(&two_stewards[1], &said("a thing", &[])), "derived Thing is exact as the repo spells it"); +} + +#[test] +fn a_name_that_overlaps_itself_is_found_where_a_whole_word_hit_follows_a_failed_one() { + assert!(names_whole_word("xab ab ab", "ab ab", false)); + assert!(names_whole_word("xab ab ab", "AB AB", false)); + assert!(names_whole_word("xab ab ab", "ab ab", true)); + assert!(!names_whole_word("xab ab abx", "ab ab", true)); + assert!(names_whole_word("é ab", "ab", true), "multibyte text steps by whole characters"); } #[test] diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 7de775c..db283e9 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -353,7 +353,7 @@ fn config() -> Config { /// ranks first among the Minds. The tests that are about the stagger name their events. fn delivery(seq: u64, room: &str, sender: &str, text: &str) -> ChatDelivery { let ranked_first = |event_id: &str| stagger_ms(&config(), &own_addressee(&config()), &ChatDelivery { event_id: event_id.into(), ..delivery_of(seq, room, sender, text, event_id) }) == 0; - let event_id = (0..).map(|n| if n == 0 { format!("$e{seq}") } else { format!("$e{seq}-{n}") }).find(|id| ranked_first(id)).unwrap(); + let event_id = (0..1000).map(|n| if n == 0 { format!("$e{seq}") } else { format!("$e{seq}-{n}") }).find(|id| ranked_first(id)).expect("among 1000 events one ranks her first: the stagger ranks nobody first"); delivery_of(seq, room, sender, text, &event_id) } @@ -6296,7 +6296,10 @@ fn three_minds(own: &str) -> Config { /// An unaddressed swarm message whose event ranks `eureka` at `rank` of three. fn unaddressed_at_rank(seq: u64, rank: u64, text: &str) -> ChatDelivery { - (0..).map(|n| delivery_of(seq, "swarm", "@op:chat", text, &format!("$r{seq}-{n}"))).find(|candidate| stagger_ms(&three_minds("@eureka:chat"), &own_addressee(&three_minds("@eureka:chat")), candidate) == rank * STAGGER_STEP_S * 1000).unwrap() + (0..1000) + .map(|n| delivery_of(seq, "swarm", "@op:chat", text, &format!("$r{seq}-{n}"))) + .find(|candidate| stagger_ms(&three_minds("@eureka:chat"), &own_addressee(&three_minds("@eureka:chat")), candidate) == rank * STAGGER_STEP_S * 1000) + .unwrap_or_else(|| panic!("among 1000 events one ranks her at {rank}: the stagger never does")) } #[test]