use std::collections::HashMap;
use crate::tui::lexicon::{LexCategory, LexHit, Lexicon};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PovChip {
pub pov: String,
pub supporting: Vec<String>,
}
pub fn compute_pov_chip(lex: &Lexicon, lines: &[String]) -> Option<PovChip> {
if lex.is_empty() || lines.is_empty() {
return None;
}
let hits_per_row: Vec<Vec<LexHit>> =
lines.iter().map(|l| lex.row_hits(l)).collect();
compute_pov_chip_from_hits(&hits_per_row, lines)
}
pub fn compute_pov_chip_from_hits(
hits_per_row: &[Vec<LexHit>],
lines: &[String],
) -> Option<PovChip> {
if hits_per_row.is_empty() {
return None;
}
let mut counts: HashMap<String, usize> = HashMap::new();
let mut first_seen: HashMap<String, usize> = HashMap::new();
let mut display: HashMap<String, String> = HashMap::new();
let mut seq: usize = 0;
for (row_idx, row_hits) in hits_per_row.iter().enumerate() {
let line = match lines.get(row_idx) {
Some(l) => l,
None => continue,
};
let chars: Vec<char> = line.chars().collect();
for hit in row_hits {
if !matches!(hit.category, LexCategory::Character) {
continue;
}
let start = hit.col_start.min(chars.len());
let end = hit.col_end.min(chars.len());
if end <= start {
continue;
}
let surface: String = chars[start..end].iter().collect();
let surface_trim = surface.trim();
if surface_trim.is_empty() {
continue;
}
let key = surface_trim.to_lowercase();
*counts.entry(key.clone()).or_insert(0) += 1;
first_seen.entry(key.clone()).or_insert(seq);
display
.entry(key.clone())
.or_insert_with(|| surface_trim.to_string());
seq += 1;
}
}
if counts.is_empty() {
return None;
}
let mut ranked: Vec<(String, usize, usize)> = counts
.iter()
.map(|(k, c)| {
let f = first_seen.get(k).copied().unwrap_or(usize::MAX);
(k.clone(), *c, f)
})
.collect();
ranked.sort_by(|a, b| {
b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2))
});
let mut iter = ranked.into_iter();
let (pov_key, _, _) = iter.next()?;
let pov_display = display
.get(&pov_key)
.cloned()
.unwrap_or(pov_key);
let supporting: Vec<String> = iter
.take(3)
.map(|(k, _, _)| display.get(&k).cloned().unwrap_or(k))
.collect();
Some(PovChip {
pov: pov_display,
supporting,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn hit(col_start: usize, col_end: usize) -> LexHit {
LexHit {
col_start,
col_end,
category: LexCategory::Character,
}
}
fn place_hit(col_start: usize, col_end: usize) -> LexHit {
LexHit {
col_start,
col_end,
category: LexCategory::Place,
}
}
#[test]
fn no_lines_no_chip() {
assert!(compute_pov_chip_from_hits(&[], &[]).is_none());
}
#[test]
fn no_character_hits_no_chip() {
let lines = vec!["Anna walked".to_string()];
let hits: Vec<Vec<LexHit>> = vec![vec![place_hit(0, 4)]];
assert!(compute_pov_chip_from_hits(&hits, &lines).is_none());
}
#[test]
fn single_character_wins() {
let lines = vec!["Anna walked away".to_string()];
let hits: Vec<Vec<LexHit>> = vec![vec![hit(0, 4)]];
let chip = compute_pov_chip_from_hits(&hits, &lines).unwrap();
assert_eq!(chip.pov, "Anna");
assert!(chip.supporting.is_empty());
}
#[test]
fn most_mentioned_wins() {
let lines = vec![
"Anna saw Bob".to_string(),
"Bob smiled".to_string(),
"Bob left".to_string(),
];
let hits: Vec<Vec<LexHit>> = vec![
vec![hit(0, 4), hit(9, 12)], vec![hit(0, 3)], vec![hit(0, 3)], ];
let chip = compute_pov_chip_from_hits(&hits, &lines).unwrap();
assert_eq!(chip.pov, "Bob");
assert_eq!(chip.supporting, vec!["Anna".to_string()]);
}
#[test]
fn ties_broken_by_first_mention() {
let lines = vec!["Anna saw Bob".to_string()];
let hits: Vec<Vec<LexHit>> = vec![vec![hit(0, 4), hit(9, 12)]];
let chip = compute_pov_chip_from_hits(&hits, &lines).unwrap();
assert_eq!(chip.pov, "Anna");
assert_eq!(chip.supporting, vec!["Bob".to_string()]);
}
#[test]
fn supporting_cast_capped_at_three() {
let line = "Anna Bob Carol Dave Eve".to_string();
let hits: Vec<Vec<LexHit>> = vec![vec![
hit(0, 4),
hit(5, 8),
hit(9, 14),
hit(15, 19),
hit(20, 23),
]];
let lines = vec![line];
let chip = compute_pov_chip_from_hits(&hits, &lines).unwrap();
assert_eq!(chip.pov, "Anna");
assert_eq!(
chip.supporting,
vec!["Bob".to_string(), "Carol".to_string(), "Dave".to_string()]
);
}
#[test]
fn case_normalised_for_count_display_preserves_first() {
let lines = vec![
"Anna laughed".to_string(),
"Then anna sighed".to_string(),
"ANNA stood up".to_string(),
];
let hits: Vec<Vec<LexHit>> = vec![
vec![hit(0, 4)],
vec![hit(5, 9)],
vec![hit(0, 4)],
];
let chip = compute_pov_chip_from_hits(&hits, &lines).unwrap();
assert_eq!(chip.pov, "Anna");
}
#[test]
fn non_character_hits_ignored() {
let lines = vec!["Anna entered Winterfell".to_string()];
let hits: Vec<Vec<LexHit>> =
vec![vec![hit(0, 4), place_hit(13, 23)]];
let chip = compute_pov_chip_from_hits(&hits, &lines).unwrap();
assert_eq!(chip.pov, "Anna");
assert!(chip.supporting.is_empty());
}
}