use std::collections::{BTreeSet, HashMap, HashSet};
use crate::{
error::table::TableError,
tables::{
chat::Chat,
diagnostic::{ChatHandleDiagnostic, column_exists},
handle::HandleGroups,
table::{CHAT_HANDLE_JOIN, CHAT_MESSAGE_JOIN, MESSAGE, Table},
},
util::union_find::UnionFind,
};
use rusqlite::{CachedStatement, Connection, Result, Row};
#[derive(Debug)]
pub struct ChatToHandle {
chat_id: i32,
handle_id: i32,
}
impl Table for ChatToHandle {
fn from_row(row: &Row) -> Result<ChatToHandle> {
Ok(ChatToHandle {
chat_id: row.get("chat_id")?,
handle_id: row.get("handle_id")?,
})
}
}
impl ChatToHandle {
pub fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
Ok(db.prepare_cached(&format!("SELECT * FROM {CHAT_HANDLE_JOIN}"))?)
}
pub fn participants_by_chat(
db: &Connection,
) -> Result<HashMap<i32, BTreeSet<i32>>, TableError> {
let mut cache: HashMap<i32, BTreeSet<i32>> = HashMap::new();
let mut rows = ChatToHandle::get(db)?;
for mapping in ChatToHandle::rows(&mut rows, [])? {
let joiner = mapping?;
if let Some(handles) = cache.get_mut(&joiner.chat_id) {
handles.insert(joiner.handle_id);
} else {
let mut data_to_cache = BTreeSet::new();
data_to_cache.insert(joiner.handle_id);
cache.insert(joiner.chat_id, data_to_cache);
}
}
Ok(cache)
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Participant {
Group(i32),
Handle(i32),
}
fn named(chats: &HashMap<i32, Chat>, chat_id: i32) -> bool {
chats.get(&chat_id).and_then(Chat::display_name).is_some()
}
impl ChatToHandle {
pub fn run_diagnostic(db: &Connection) -> Result<ChatHandleDiagnostic, TableError> {
let mut statement_message_chats =
db.prepare(&format!("SELECT DISTINCT chat_id from {CHAT_MESSAGE_JOIN}"))?;
let statement_message_chat_rows =
statement_message_chats.query_map([], |row: &Row| -> Result<i32> { row.get(0) })?;
let mut unique_chats_from_messages: HashSet<i32> = HashSet::new();
statement_message_chat_rows.into_iter().for_each(|row| {
if let Ok(row) = row {
unique_chats_from_messages.insert(row);
}
});
let mut statement_handle_chats =
db.prepare(&format!("SELECT DISTINCT chat_id from {CHAT_HANDLE_JOIN}"))?;
let statement_handle_chat_rows =
statement_handle_chats.query_map([], |row: &Row| -> Result<i32> { row.get(0) })?;
let mut unique_chats_from_handles: HashSet<i32> = HashSet::new();
statement_handle_chat_rows.into_iter().for_each(|row| {
if let Ok(row) = row {
unique_chats_from_handles.insert(row);
}
});
let all_chats = Self::participants_by_chat(db)?;
let chat_handle_lookup = ChatToHandle::get_chat_lookup_map(db)?;
let handle_groups = HandleGroups::from_db(db)?.finalize();
let chats = Chat::by_rowid(db)?;
let real_chatrooms =
ChatToHandle::dedupe(&all_chats, &chat_handle_lookup, &handle_groups, &chats);
let conversations: HashSet<&i32> = all_chats
.keys()
.filter_map(|chat_id| real_chatrooms.get(chat_id))
.collect();
let total_duplicated = all_chats.len() - conversations.len();
let chats_with_no_handles = unique_chats_from_messages
.difference(&unique_chats_from_handles)
.count();
Ok(ChatHandleDiagnostic {
total_chats: all_chats.len(),
total_duplicated,
chats_with_no_handles,
})
}
}
impl ChatToHandle {
pub fn get_chat_lookup_map(conn: &Connection) -> Result<HashMap<i32, i32>, TableError> {
let mut stmt = conn.prepare(
"
WITH RECURSIVE
adj AS (
SELECT DISTINCT a.chat AS u, b.chat AS v
FROM chat_lookup a
JOIN chat_lookup b
ON a.identifier = b.identifier
),
reach(root, chat) AS (
SELECT u AS root, v AS chat FROM adj
UNION
SELECT r.root, a.v
FROM reach r
JOIN adj a ON a.u = r.chat
),
canon AS (
SELECT chat, MAX(root) AS canonical_chat
FROM reach
GROUP BY chat
)
SELECT chat, canonical_chat
FROM canon
ORDER BY chat;
",
);
let mut chat_lookup_map: HashMap<i32, i32> = HashMap::new();
if let Ok(statement) = stmt.as_mut() {
let chat_lookup_rows = statement.query_map([], |row| {
let chat: i32 = row.get(0)?;
let canonical: i32 = row.get(1)?;
Ok((chat, canonical))
});
if let Ok(chat_lookup_rows) = chat_lookup_rows {
for row in chat_lookup_rows {
let (chat_id, canonical_chat) = row?;
chat_lookup_map.insert(chat_id, canonical_chat);
}
}
}
Ok(chat_lookup_map)
}
pub fn dedupe(
duplicated_data: &HashMap<i32, BTreeSet<i32>>,
chat_lookup_map: &HashMap<i32, i32>,
handle_groups: &HashMap<i32, i32>,
chats: &HashMap<i32, Chat>,
) -> HashMap<i32, i32> {
let mut uf = UnionFind::new();
for chat_id in duplicated_data.keys() {
uf.make_set(*chat_id);
}
let mut sorted_lookup: Vec<(&i32, &i32)> = chat_lookup_map.iter().collect();
sorted_lookup.sort_by_key(|(chat_id, _)| *chat_id);
for (chat_id, canonical) in sorted_lookup {
uf.union(*chat_id, *canonical);
}
let mut keyed: Vec<(i32, BTreeSet<Participant>, Option<&str>)> = duplicated_data
.iter()
.map(|(chat_id, participants)| {
let key = participants
.iter()
.map(|handle_id| match handle_groups.get(handle_id) {
Some(group) => Participant::Group(*group),
None => Participant::Handle(*handle_id),
})
.collect();
let name = chats.get(chat_id).and_then(Chat::display_name);
(*chat_id, key, name)
})
.collect();
keyed.sort_by_key(|(id, _, _)| *id);
let mut first_named: HashMap<(&BTreeSet<Participant>, &str), i32> = HashMap::new();
let mut anchor: HashMap<&BTreeSet<Participant>, i32> = HashMap::new();
for (chat_id, key, name) in &keyed {
let Some(name) = name else { continue };
match first_named.get(&(key, *name)) {
Some(first_chat) => uf.union(*chat_id, *first_chat),
None => {
first_named.insert((key, name), *chat_id);
}
}
anchor.entry(key).or_insert(*chat_id);
}
let mut lookup_members: Vec<i32> = chat_lookup_map
.iter()
.flat_map(|(chat_id, canonical)| [*chat_id, *canonical])
.collect();
lookup_members.sort_unstable();
lookup_members.dedup();
let mut named_roots: HashSet<i32> = keyed
.iter()
.filter(|(_, _, name)| name.is_some())
.map(|(chat_id, _, _)| *chat_id)
.chain(
lookup_members
.iter()
.copied()
.filter(|chat_id| named(chats, *chat_id)),
)
.map(|chat_id| uf.find(chat_id))
.collect();
for (chat_id, key, name) in &keyed {
if name.is_some() {
continue;
}
let row_named = named_roots.contains(&uf.find(*chat_id));
match anchor.get(key) {
Some(first_chat) => {
let anchor_named = named_roots.contains(&uf.find(*first_chat));
if row_named && anchor_named {
continue;
}
uf.union(*chat_id, *first_chat);
if row_named || anchor_named {
named_roots.insert(uf.find(*chat_id));
}
}
None => {
anchor.insert(key, *chat_id);
}
}
}
let lookup_only = lookup_members
.iter()
.copied()
.filter(|member| !duplicated_data.contains_key(member));
uf.label(keyed.iter().map(|(id, _, _)| *id).chain(lookup_only))
}
}
impl ChatToHandle {
pub fn get_last_activity(db: &Connection) -> Result<HashMap<i32, i64>, TableError> {
let query = if column_exists(db, CHAT_MESSAGE_JOIN, "message_date")? {
format!("SELECT chat_id, MAX(message_date) FROM {CHAT_MESSAGE_JOIN} GROUP BY chat_id")
} else {
format!(
"SELECT j.chat_id, MAX(m.date) FROM {CHAT_MESSAGE_JOIN} j JOIN {MESSAGE} m ON m.ROWID = j.message_id GROUP BY j.chat_id"
)
};
let mut statement = db.prepare(&query)?;
let rows = statement.query_map([], |row| {
Ok((row.get::<_, i32>(0)?, row.get::<_, Option<i64>>(1)?))
})?;
let mut last_activity = HashMap::new();
for row in rows {
let (chat_id, date) = row?;
if let Some(date) = date {
last_activity.insert(chat_id, date);
}
}
Ok(last_activity)
}
pub fn representatives(
deduplicated_chats: &HashMap<i32, i32>,
chats: &HashMap<i32, Chat>,
last_activity: &HashMap<i32, i64>,
participants_by_chat: &HashMap<i32, BTreeSet<i32>>,
) -> HashMap<i32, i32> {
let mut members: HashMap<i32, Vec<i32>> = HashMap::new();
for (chat_id, dedup_id) in deduplicated_chats {
members.entry(*dedup_id).or_default().push(*chat_id);
}
let activity = |chat_id: &i32| {
(
last_activity.get(chat_id).copied().unwrap_or(i64::MIN),
std::cmp::Reverse(*chat_id),
)
};
let mut representatives = HashMap::with_capacity(deduplicated_chats.len());
for rows in members.into_values() {
let most_active = rows
.iter()
.copied()
.max_by_key(activity)
.expect("a deduplicated group has at least one row");
let representative = if named(chats, most_active) {
most_active
} else {
rows.iter()
.copied()
.filter(|chat_id| named(chats, *chat_id))
.min()
.or_else(|| {
rows.iter()
.copied()
.filter(|chat_id| participants_by_chat.contains_key(chat_id))
.max_by_key(activity)
})
.unwrap_or(most_active)
};
for chat_id in rows {
representatives.insert(chat_id, representative);
}
}
representatives
}
}
#[cfg(test)]
mod tests {
use crate::tables::chat_handle::ChatToHandle;
use std::collections::{BTreeSet, HashMap, HashSet};
#[test]
fn can_dedupe() {
let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
input.insert(1, BTreeSet::from([1])); input.insert(2, BTreeSet::from([1])); input.insert(3, BTreeSet::from([1])); input.insert(4, BTreeSet::from([2])); input.insert(5, BTreeSet::from([2])); input.insert(6, BTreeSet::from([3]));
let output =
ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &HashMap::new());
let expected: HashMap<i32, i32> =
HashMap::from([(1, 0), (2, 0), (3, 0), (4, 1), (5, 1), (6, 2)]);
assert_eq!(output, expected);
}
#[test]
fn can_dedupe_multi() {
let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
input.insert(1, BTreeSet::from([1, 2])); input.insert(2, BTreeSet::from([1])); input.insert(3, BTreeSet::from([1])); input.insert(4, BTreeSet::from([2, 1])); input.insert(5, BTreeSet::from([2, 3])); input.insert(6, BTreeSet::from([3]));
let output =
ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &HashMap::new());
let expected: HashMap<i32, i32> =
HashMap::from([(1, 0), (2, 1), (3, 1), (4, 0), (5, 2), (6, 3)]);
assert_eq!(output, expected);
}
#[test]
fn can_dedupe_with_chat_lookup_map() {
let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
input.insert(0, BTreeSet::from([1])); input.insert(1, BTreeSet::from([1])); input.insert(2, BTreeSet::from([3])); input.insert(4, BTreeSet::from([2])); input.insert(5, BTreeSet::from([1]));
let chat_lookup_map = HashMap::from([(2, 5), (4, 0)]);
let output =
ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
assert_eq!(output.get(&0), output.get(&1));
assert_eq!(output.get(&0), output.get(&4));
assert_eq!(output.get(&0), output.get(&5));
assert_eq!(output.get(&2), output.get(&5));
}
#[test]
fn can_dedupe_with_lookup_map_overriding_participants() {
let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
input.insert(0, BTreeSet::from([1, 2])); input.insert(1, BTreeSet::from([1, 2])); input.insert(2, BTreeSet::from([3, 4])); input.insert(3, BTreeSet::from([1, 2]));
let chat_lookup_map = HashMap::from([(1, 0), (2, 0)]);
let output =
ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
assert_eq!(output.get(&0), output.get(&1));
assert_eq!(output.get(&0), output.get(&2));
assert_eq!(output.get(&3), output.get(&0));
}
#[test]
fn can_dedupe_mixed_lookup_and_participants() {
let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
input.insert(0, BTreeSet::from([1])); input.insert(1, BTreeSet::from([1])); input.insert(2, BTreeSet::from([3])); input.insert(3, BTreeSet::from([2])); input.insert(4, BTreeSet::from([3]));
let chat_lookup_map = HashMap::from([(1, 0), (3, 0)]);
let output =
ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
assert_eq!(output.get(&0), output.get(&1));
assert_eq!(output.get(&0), output.get(&3));
assert_ne!(output.get(&2), output.get(&1));
assert_eq!(output.get(&4), output.get(&2));
assert_ne!(output.get(&3), output.get(&4));
}
#[test]
fn lookup_merge_is_order_independent() {
let cases = [
(
"canonical is the lower id",
HashMap::from([(0, BTreeSet::from([1, 2])), (5, BTreeSet::from([3, 4]))]),
HashMap::from([(0, 0), (5, 0)]),
HashMap::from([(0, 0), (5, 0)]),
),
(
"canonical is the higher id",
HashMap::from([(0, BTreeSet::from([1, 2])), (5, BTreeSet::from([3, 4]))]),
HashMap::from([(0, 5), (5, 5)]),
HashMap::from([(0, 0), (5, 0)]),
),
(
"iMessage, SMS, and RCS splits share one canonical",
HashMap::from([
(100, BTreeSet::from([10])),
(200, BTreeSet::from([20])),
(300, BTreeSet::from([30])),
]),
HashMap::from([(100, 300), (200, 300), (300, 300)]),
HashMap::from([(100, 0), (200, 0), (300, 0)]),
),
];
for (label, input, chat_lookup_map, expected) in cases {
let output =
ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
assert_eq!(output, expected, "{label}");
}
}
#[test]
fn lookup_merges_through_missing_canonical() {
let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([2]))]);
let chat_lookup_map = HashMap::from([(10, 99), (20, 99)]);
let output =
ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
assert_eq!(
output.get(&10),
output.get(&20),
"Chats sharing a canonical absent from duplicated_data should still merge"
);
assert_eq!(output.len(), 3);
assert_eq!(output.get(&99), output.get(&10));
}
#[test]
fn lookup_component_without_handle_rows_gets_its_own_id() {
let input = HashMap::from([(10, BTreeSet::from([1]))]);
let chat_lookup_map = HashMap::from([(10, 20), (30, 40)]);
let output =
ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
assert_eq!(output.len(), 4);
assert_eq!(output.get(&10), Some(&0));
assert_eq!(output.get(&20), Some(&0));
assert_eq!(output.get(&30), Some(&1));
assert_eq!(output.get(&40), Some(&1));
}
#[test]
fn chats_differing_only_by_grouped_handles_merge() {
let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
input.insert(10, BTreeSet::from([1, 3]));
input.insert(20, BTreeSet::from([2, 3]));
input.insert(30, BTreeSet::from([1, 2, 3]));
let groups: HashMap<i32, i32> = HashMap::from([(1, 0), (2, 0), (3, 1)]);
let output = ChatToHandle::dedupe(&input, &HashMap::new(), &groups, &HashMap::new());
assert_eq!(output.get(&10), output.get(&20));
assert_eq!(
output.get(&10),
output.get(&30),
"a chat listing both of a person's handles is the same participant set"
);
}
#[test]
fn chats_differing_by_group_stay_separate() {
let input = HashMap::from([(10, BTreeSet::from([1, 3])), (20, BTreeSet::from([2, 3]))]);
let groups: HashMap<i32, i32> = HashMap::from([(1, 0), (2, 5), (3, 1)]);
let output = ChatToHandle::dedupe(&input, &HashMap::new(), &groups, &HashMap::new());
assert_ne!(output.get(&10), output.get(&20));
}
#[test]
fn ungrouped_handle_does_not_collide_with_group_id() {
let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([7]))]);
let groups: HashMap<i32, i32> = HashMap::from([(1, 7)]);
let output = ChatToHandle::dedupe(&input, &HashMap::new(), &groups, &HashMap::new());
assert_ne!(output.get(&10), output.get(&20));
}
#[test]
fn grouped_merge_is_transitive_with_lookup() {
let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
input.insert(10, BTreeSet::from([1]));
input.insert(20, BTreeSet::from([2]));
input.insert(30, BTreeSet::from([9]));
let groups: HashMap<i32, i32> = HashMap::from([(1, 0), (2, 0), (9, 4)]);
let lookup: HashMap<i32, i32> = HashMap::from([(30, 20), (20, 20)]);
let output = ChatToHandle::dedupe(&input, &lookup, &groups, &HashMap::new());
let unique: HashSet<i32> = output.values().copied().collect();
assert_eq!(unique.len(), 1);
}
fn chat(rowid: i32, name: Option<&str>) -> crate::tables::chat::Chat {
crate::tables::chat::Chat {
rowid,
chat_identifier: String::new(),
service_name: None,
display_name: name.map(str::to_string),
is_filtered: None,
is_blackholed: None,
is_pending_review: None,
}
}
#[test]
fn representative_is_the_most_active_named_row() {
let dedup: HashMap<i32, i32> = HashMap::from([(10, 0), (20, 0), (30, 1)]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, Some("Old Name"))),
(20, chat(20, Some("New Name"))),
(30, chat(30, None)),
]);
let activity: HashMap<i32, i64> = HashMap::from([(10, 100), (20, 200)]);
let reps = ChatToHandle::representatives(&dedup, &chats, &activity, &HashMap::new());
assert_eq!(reps[&10], 20);
assert_eq!(reps[&20], 20);
assert_eq!(reps[&30], 30);
}
#[test]
fn unnamed_active_row_defers_to_a_named_sibling() {
let dedup: HashMap<i32, i32> = HashMap::from([(10, 0), (20, 0), (25, 0)]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, Some("Sardegnas"))),
(20, chat(20, Some(""))),
(25, chat(25, None)),
]);
let activity: HashMap<i32, i64> = HashMap::from([(10, 100), (20, 300), (25, 200)]);
let reps = ChatToHandle::representatives(&dedup, &chats, &activity, &HashMap::new());
assert_eq!(reps[&10], 10);
assert_eq!(reps[&20], 10);
assert_eq!(reps[&25], 10);
}
#[test]
fn unnamed_group_uses_its_most_active_row_with_lowest_id_tiebreak() {
let dedup: HashMap<i32, i32> = HashMap::from([(10, 0), (20, 0), (30, 0)]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, None)),
(20, chat(20, None)),
(30, chat(30, None)),
]);
let activity: HashMap<i32, i64> = HashMap::from([(20, 500), (30, 500)]);
let reps = ChatToHandle::representatives(&dedup, &chats, &activity, &HashMap::new());
assert_eq!(reps[&10], 20);
assert_eq!(reps[&20], 20);
assert_eq!(reps[&30], 20);
}
#[test]
fn unnamed_group_representative_has_participant_rows() {
let dedup: HashMap<i32, i32> = HashMap::from([(10, 0), (20, 0), (99, 0)]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, None)),
(20, chat(20, None)),
(99, chat(99, None)),
]);
let activity: HashMap<i32, i64> = HashMap::from([(10, 100), (20, 200), (99, 300)]);
let participants: HashMap<i32, BTreeSet<i32>> =
HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
let reps = ChatToHandle::representatives(&dedup, &chats, &activity, &participants);
assert_eq!(reps[&10], 20);
assert_eq!(reps[&20], 20);
assert_eq!(reps[&99], 20);
}
#[test]
fn diagnostic_counts_duplicates_over_chats_with_handle_rows() {
let db = rusqlite::Connection::open_in_memory().unwrap();
db.execute_batch(
"CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, service_name TEXT, display_name TEXT);
CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT, person_centric_id TEXT);
CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);
CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);
CREATE TABLE chat_lookup (chat INTEGER, identifier TEXT);
INSERT INTO chat VALUES (10, 'a', 'iMessage', NULL), (30, 'b', 'iMessage', NULL), (40, 'c', 'SMS', NULL);
INSERT INTO handle VALUES (1, '+15555550100', NULL);
INSERT INTO chat_handle_join VALUES (10, 1);
INSERT INTO chat_message_join VALUES (10, 1), (30, 2);
INSERT INTO chat_lookup VALUES (30, 'x'), (40, 'x');",
)
.unwrap();
let diagnostic = ChatToHandle::run_diagnostic(&db).unwrap();
assert_eq!(diagnostic.total_chats, 1);
assert_eq!(diagnostic.total_duplicated, 0);
assert_eq!(diagnostic.chats_with_no_handles, 1);
}
#[test]
fn last_activity_reads_message_date_when_present() {
let db = rusqlite::Connection::open_in_memory().unwrap();
db.execute_batch(
"CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER, message_date INTEGER DEFAULT 0);
INSERT INTO chat_message_join VALUES (1, 1, 10), (1, 2, 30), (2, 3, 20);",
)
.unwrap();
let activity = ChatToHandle::get_last_activity(&db).unwrap();
assert_eq!(activity[&1], 30);
assert_eq!(activity[&2], 20);
}
#[test]
fn last_activity_falls_back_to_message_table() {
let db = rusqlite::Connection::open_in_memory().unwrap();
db.execute_batch(
"CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);
CREATE TABLE message (ROWID INTEGER PRIMARY KEY, date INTEGER);
INSERT INTO message VALUES (1, 10), (2, 30), (3, 20);
INSERT INTO chat_message_join VALUES (1, 1), (1, 2), (2, 3);",
)
.unwrap();
let activity = ChatToHandle::get_last_activity(&db).unwrap();
assert_eq!(activity[&1], 30);
assert_eq!(activity[&2], 20);
}
#[test]
fn same_people_with_different_names_are_different_groups() {
let input = HashMap::from([(10, BTreeSet::from([1, 2])), (20, BTreeSet::from([1, 2]))]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, Some("Sunday Call"))),
(20, chat(20, Some("Da Boys"))),
]);
let output = ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &chats);
assert_ne!(output.get(&10), output.get(&20));
}
#[test]
fn same_people_with_the_same_name_merge() {
let input = HashMap::from([(10, BTreeSet::from([1, 2])), (20, BTreeSet::from([1, 2]))]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, Some("Family"))),
(20, chat(20, Some("Family"))),
]);
let output = ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &chats);
assert_eq!(output.get(&10), output.get(&20));
}
#[test]
fn unnamed_row_joins_the_first_named_row_with_its_people() {
let input = HashMap::from([10, 20, 30, 40].map(|id| (id, BTreeSet::from([1, 2]))));
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, Some("Sunday Call"))),
(20, chat(20, Some("Da Boys"))),
(30, chat(30, None)),
(40, chat(40, Some(""))),
]);
let output = ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &chats);
assert_eq!(output.get(&30), output.get(&10));
assert_eq!(output.get(&40), output.get(&10));
assert_ne!(output.get(&30), output.get(&20));
}
#[test]
fn unnamed_rows_merge_with_each_other_when_nothing_is_named() {
let input = HashMap::from([(10, BTreeSet::from([1, 2])), (20, BTreeSet::from([1, 2]))]);
let chats: HashMap<i32, crate::tables::chat::Chat> =
HashMap::from([(10, chat(10, None)), (20, chat(20, None))]);
let output = ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &chats);
assert_eq!(output.get(&10), output.get(&20));
}
#[test]
fn unnamed_row_keeps_its_chat_lookup_conversation() {
let input = HashMap::from([10, 20, 30].map(|id| (id, BTreeSet::from([1, 2]))));
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, Some("Alpha"))),
(20, chat(20, Some("Beta"))),
(30, chat(30, None)),
]);
let lookup: HashMap<i32, i32> = HashMap::from([(30, 20)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
assert_eq!(output.get(&30), output.get(&20));
assert_ne!(output.get(&10), output.get(&20));
}
#[test]
fn unnamed_row_linked_to_an_unnamed_row_still_joins_a_named_row() {
let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
input.insert(10, BTreeSet::from([1, 2]));
input.insert(30, BTreeSet::from([1, 2]));
input.insert(40, BTreeSet::from([1, 2, 3]));
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, Some("Alpha"))),
(30, chat(30, None)),
(40, chat(40, None)),
]);
let lookup: HashMap<i32, i32> = HashMap::from([(40, 30)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
assert_eq!(output.get(&30), output.get(&10));
assert_eq!(output.get(&40), output.get(&10));
}
#[test]
fn unnamed_anchor_does_not_block_later_unnamed_merges() {
let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
input.insert(10, BTreeSet::from([1]));
input.insert(20, BTreeSet::from([1]));
input.insert(30, BTreeSet::from([2]));
input.insert(40, BTreeSet::from([2]));
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, None)),
(20, chat(20, None)),
(30, chat(30, None)),
(40, chat(40, None)),
]);
let lookup: HashMap<i32, i32> = HashMap::from([(20, 30)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
assert_eq!(output.get(&10), output.get(&20));
assert_eq!(output.get(&20), output.get(&30));
assert_eq!(output.get(&30), output.get(&40));
}
#[test]
fn named_lookup_canonical_without_handle_rows_still_counts_as_named() {
let input = HashMap::from([(10, BTreeSet::from([1, 2])), (20, BTreeSet::from([1, 2]))]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, Some("Alpha"))),
(20, chat(20, None)),
(99, chat(99, Some("Beta"))),
]);
let lookup: HashMap<i32, i32> = HashMap::from([(20, 99)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
assert_ne!(output.get(&10), output.get(&20));
}
#[test]
fn lookup_members_without_handle_rows_join_the_conversation() {
let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, Some("Beta"))),
(20, chat(20, None)),
(88, chat(88, Some("Alpha"))),
(99, chat(99, None)),
]);
let lookup: HashMap<i32, i32> = HashMap::from([(20, 99), (88, 99), (99, 99)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
assert_ne!(output.get(&10), output.get(&20));
assert_eq!(output.get(&88), output.get(&20));
assert_eq!(output.get(&99), output.get(&20));
}
#[test]
fn unnamed_rows_join_a_sibling_linked_to_a_named_row() {
let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, None)),
(20, chat(20, None)),
(88, chat(88, Some("Alpha"))),
]);
let lookup: HashMap<i32, i32> = HashMap::from([(10, 88), (88, 88)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
assert_eq!(output.get(&10), output.get(&20));
assert_eq!(output.get(&10), output.get(&88));
}
#[test]
fn linked_row_pulls_in_an_earlier_unnamed_anchor() {
let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, None)),
(20, chat(20, None)),
(88, chat(88, Some("Alpha"))),
]);
let lookup: HashMap<i32, i32> = HashMap::from([(20, 88), (88, 88)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
assert_eq!(output.get(&10), output.get(&20));
assert_eq!(output.get(&10), output.get(&88));
}
#[test]
fn unlinked_row_joins_the_lower_of_two_linked_conversations() {
let input = HashMap::from([10, 20, 30].map(|id| (id, BTreeSet::from([1]))));
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, None)),
(20, chat(20, None)),
(30, chat(30, None)),
(88, chat(88, Some("Alpha"))),
(99, chat(99, Some("Beta"))),
]);
let lookup: HashMap<i32, i32> = HashMap::from([(10, 88), (88, 88), (20, 99), (99, 99)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
assert_ne!(output.get(&10), output.get(&20));
assert_eq!(output.get(&30), output.get(&10));
assert_eq!(output.get(&88), output.get(&10));
assert_eq!(output.get(&99), output.get(&20));
}
#[test]
fn lookup_only_names_reach_the_representative() {
let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
(10, chat(10, None)),
(20, chat(20, None)),
(88, chat(88, Some("Alpha"))),
(99, chat(99, Some("Beta"))),
]);
let lookup: HashMap<i32, i32> = HashMap::from([(10, 88), (88, 88), (20, 99), (99, 99)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
let representatives =
ChatToHandle::representatives(&output, &chats, &HashMap::new(), &input);
assert_ne!(output.get(&10), output.get(&20));
assert_eq!(representatives.get(&10), Some(&88));
assert_eq!(representatives.get(&20), Some(&99));
}
#[test]
fn chat_lookup_still_joins_differently_named_rows() {
let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([2]))]);
let chats: HashMap<i32, crate::tables::chat::Chat> =
HashMap::from([(10, chat(10, Some("A"))), (20, chat(20, Some("B")))]);
let lookup: HashMap<i32, i32> = HashMap::from([(10, 20), (20, 20)]);
let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
assert_eq!(output.get(&10), output.get(&20));
}
}