diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/deck.rs | 145 | ||||
-rw-r--r-- | src/gui/message.rs | 16 | ||||
-rw-r--r-- | src/gui/mod.rs | 70 | ||||
-rw-r--r-- | src/gui/question.rs | 188 | ||||
-rw-r--r-- | src/main.rs | 38 | ||||
-rw-r--r-- | src/model/difficulty.rs | 2 | ||||
-rw-r--r-- | src/model/mod.rs | 2 | ||||
-rw-r--r-- | src/sync.rs | 206 | ||||
-rw-r--r-- | src/util/event.rs | 73 | ||||
-rw-r--r-- | src/util/mod.rs | 1 | ||||
-rw-r--r-- | src/util/serialization.rs | 18 |
11 files changed, 441 insertions, 318 deletions
diff --git a/src/deck.rs b/src/deck.rs index 0c302e1..4491d9b 100644 --- a/src/deck.rs +++ b/src/deck.rs @@ -23,51 +23,57 @@ impl std::error::Error for ParseError { } } -pub fn read(deck: &str) -> Result<Vec<Line>> { - let file = File::open(deck)?; +pub fn read_file(path: &str) -> Result<Vec<Line>> { + let file = File::open(path)?; let reader = BufReader::new(file); let mut entries: Vec<Line> = Vec::new(); for (index, line) in reader.lines().enumerate() { let line = line?; - let line = line.trim(); - if !line.starts_with('#') && !line.is_empty() { - if !line.starts_with('-') { - return Err(Error::from(ParseError { + if let Some(line) = read_line(index, &line)? { + entries.push(line) + } + } + + Ok(entries) +} + +fn read_line(index: usize, line: &str) -> Result<Option<Line>> { + let line = line.trim(); + + if line.starts_with('#') || line.is_empty() { + Ok(None) + } else if !line.starts_with('-') { + Err(Error::from(ParseError { + line: index + 1, + message: "an entry should starts with “-”.".to_string(), + })) + } else { + let without_minus = line.split('-').skip(1).collect::<Vec<&str>>().join("-"); + let without_comment = without_minus.split('#').collect::<Vec<&str>>()[0].trim(); + let translation = without_comment.split(':').collect::<Vec<&str>>(); + if translation.len() != 2 { + Err(Error::from(ParseError { + line: index + 1, + message: "an entry should contain two parts separated by “:”.".to_string(), + })) + } else { + let t1 = translation[0].trim(); + let t2 = translation[1].trim(); + if t1.is_empty() || t2.is_empty() { + Err(Error::from(ParseError { line: index + 1, - message: "an entry should starts with “-”.".to_string(), - })); + message: "an entry should contain two parts separated by “:”.".to_string(), + })) } else { - let without_minus = line.split('-').skip(1).collect::<Vec<&str>>().join("-"); - let without_comment = without_minus.split('#').collect::<Vec<&str>>()[0].trim(); - let translation = without_comment.split(':').collect::<Vec<&str>>(); - if translation.len() != 2 { - return Err(Error::from(ParseError { - line: index + 1, - message: "an entry should contain two parts separated by “:”.".to_string(), - })); - } else { - let t1 = translation[0].trim(); - let t2 = translation[1].trim(); - if t1.is_empty() || t2.is_empty() { - return Err(Error::from(ParseError { - line: index + 1, - message: "an entry should contain two parts separated by “:”." - .to_string(), - })); - } else { - entries.push(Line { - part_1: serialization::line_to_words(t1), - part_2: serialization::line_to_words(t2), - }) - } - } + Ok(Some(Line { + part_1: serialization::line_to_words(t1), + part_2: serialization::line_to_words(t2), + })) } } } - - Ok(entries) } pub fn pp_from_path(path: &str) -> Option<String> { @@ -83,3 +89,74 @@ fn capitalize(s: &str) -> String { Some(f) => f.to_uppercase().collect::<String>() + c.as_str(), } } + +#[cfg(test)] +pub mod tests { + + use crate::model::Line; + use anyhow::Result; + + #[test] + fn errors() { + is_error("A : a"); + is_error("- A"); + is_error("- A -> a"); + is_error("- A : B : C"); + is_error("- : "); + is_error("- A : a\n-") + } + + #[test] + fn ignored() { + check("", &[]); + check(" ", &[]); + check(" \n \n ", &[]); + check("# 1", &[]); + check("# 1\n\n # 2", &[]); + } + + #[test] + fn card() { + check("- A : a", &[(&["A"], &["a"])]); + } + + #[test] + fn cards() { + check("- A : a\n- B : b", &[(&["A"], &["a"]), (&["B"], &["b"])]); + } + + #[test] + fn alternatives() { + check("- A : a1 | a2", &[(&["A"], &["a1", "a2"])]); + check("- A1 | A2 : a", &[(&["A1", "A2"], &["a"])]); + check("- A1 | A2 : a1 | a2", &[(&["A1", "A2"], &["a1", "a2"])]); + } + + fn is_error(content: &str) { + assert!(read_string(content).is_err()) + } + + fn check(content: &str, res: &[(&[&str], &[&str])]) { + assert_eq!( + read_string(content).unwrap(), + res.iter() + .map(|(part_1, part_2)| Line { + part_1: part_1.iter().map(|x| x.to_string()).collect::<Vec<_>>(), + part_2: part_2.iter().map(|x| x.to_string()).collect::<Vec<_>>() + }) + .collect::<Vec<_>>() + ) + } + + pub fn read_string(content: &str) -> Result<Vec<Line>> { + let mut entries: Vec<Line> = Vec::new(); + + for (index, line) in content.lines().enumerate() { + if let Some(line) = super::read_line(index, line)? { + entries.push(line) + } + } + + Ok(entries) + } +} diff --git a/src/gui/message.rs b/src/gui/message.rs index 29b5d8a..61f57ba 100644 --- a/src/gui/message.rs +++ b/src/gui/message.rs @@ -1,7 +1,6 @@ use crate::gui::util; -use crate::util::event::{Event, Events}; use anyhow::Result; -use termion::event::Key; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use tui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout}, @@ -11,7 +10,6 @@ use tui::{ pub fn show<B: Backend>( terminal: &mut Terminal<B>, - events: &Events, title: &str, message: &str, wait: bool, @@ -33,12 +31,12 @@ pub fn show<B: Backend>( })?; if wait { - if let Event::Input(key) = events.next()? { - match key { - Key::Char('q') | Key::Ctrl('c') => { - break; - } - _ => {} + if let Event::Key(key) = event::read()? { + if key.code == KeyCode::Char('q') + || key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + break; } } } else { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 358e4b5..3abe238 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2,34 +2,59 @@ pub mod message; pub mod question; pub mod util; -use crate::{db, space_repetition, util::event::Events, util::time}; +use crate::sync; +use crate::{db, space_repetition, util::time}; use anyhow::Result; +use crossterm::terminal; use rusqlite::Connection; -use std::io; -use termion::{raw::IntoRawMode, raw::RawTerminal, screen::AlternateScreen}; -use tui::{backend::TermionBackend, Terminal}; +use std::fs; +use std::io::Stdout; +use tui::{backend::CrosstermBackend, Terminal}; -pub type Term = Terminal<TermionBackend<AlternateScreen<RawTerminal<io::Stdout>>>>; +pub type Term = Terminal<CrosstermBackend<Stdout>>; -pub fn terminal() -> Result<Term> { - let stdout = io::stdout().into_raw_mode()?; - let stdout = AlternateScreen::from(stdout); - let backend = TermionBackend::new(stdout); +pub fn setup_terminal() -> Result<Term> { + terminal::enable_raw_mode()?; + let mut stdout = std::io::stdout(); + crossterm::execute!(stdout, terminal::EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); Ok(Terminal::new(backend)?) } -pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &str) -> Result<()> { - let mut answers = 0; +pub fn restore_terminal(term: &mut Term) -> Result<()> { + terminal::disable_raw_mode()?; + crossterm::execute!(term.backend_mut(), terminal::LeaveAlternateScreen)?; + term.show_cursor()?; + Ok(()) +} +pub fn start( + conn: &mut Connection, + term: &mut Term, + deck_path: &str, + deck_name: &str, + mut deck_last_sync: u64, + hide_remaining: bool, +) -> Result<()> { loop { - let now = time::seconds_since_unix_epoch()?; - let title = title(deck_name, answers, db::count_available(conn).unwrap_or(0)); + // Synchronize deck if necessary + let deck_last_update = + time::seconds_since_unix_epoch_of(fs::metadata(deck_path)?.modified()?)?; + if deck_last_update > deck_last_sync { + sync::run(conn, deck_path)?; + deck_last_sync = time::seconds_since_unix_epoch()?; + } + + let title = title( + deck_name, + db::count_available(conn).unwrap_or(0), + hide_remaining, + ); match db::pick_random_ready(conn) { - Some(card) => match question::ask(term, events, &title, &card)? { + Some(card) => match question::ask(term, &title, &card)? { question::Response::Aborted => break, question::Response::Answered { difficulty } => { - answers += 1; db::update( conn, &card.question, @@ -40,12 +65,13 @@ pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &st None => { let message = match db::next_ready(conn) { Some(ready) => { + let now = time::seconds_since_unix_epoch()?; let duration = time::pp_duration(ready - now); format!("Prochaine carte disponible dans {duration}.") } None => "Aucune carte n’est disponible. Votre deck est-il vide ?".to_string(), }; - let _ = message::show(term, events, &title, &message, true); + let _ = message::show(term, &title, &message, true); break; } } @@ -54,16 +80,10 @@ pub fn start(conn: &Connection, term: &mut Term, events: &Events, deck_name: &st Ok(()) } -fn title(deck_name: &str, answers: i32, available_cards: i32) -> String { - if answers == 0 && available_cards == 0 { +fn title(deck_name: &str, available_cards: i32, hide_remaining: bool) -> String { + if available_cards == 0 || hide_remaining { deck_name.to_string() - } else if available_cards == 0 { - let from = answers; - let to = answers + available_cards; - format!("{deck_name} ({from} / {to})") } else { - let from = answers + 1; - let to = answers + available_cards; - format!("{deck_name} ({from} / {to})") + format!("{deck_name} ({available_cards})") } } diff --git a/src/gui/question.rs b/src/gui/question.rs index 2aa6e65..512ca49 100644 --- a/src/gui/question.rs +++ b/src/gui/question.rs @@ -1,17 +1,16 @@ use crate::{ gui::util, model::{difficulty, difficulty::Difficulty, Card}, - util::event::{Event, Events}, util::serialization, }; use anyhow::Result; -use termion::event::Key; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use tui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - text::{Span, Spans, Text}, - widgets::{Block, Borders, Paragraph, Wrap}, + text::{Line, Span, Text}, + widgets::{Paragraph, Wrap}, Terminal, }; @@ -30,12 +29,7 @@ pub enum Response { Answered { difficulty: Difficulty }, } -pub fn ask<B: Backend>( - terminal: &mut Terminal<B>, - events: &Events, - title: &str, - card: &Card, -) -> Result<Response> { +pub fn ask<B: Backend>(terminal: &mut Terminal<B>, title: &str, card: &Card) -> Result<Response> { let mut state = State { input: String::new(), answer: Answer::Write, @@ -62,16 +56,6 @@ pub fn ask<B: Backend>( f.render_widget(d1, chunks[0]); let question = Paragraph::new(util::center_vertically(chunks[1], &card.question)) - .style(match state.answer { - Answer::Write => { - if state.input.trim().is_empty() { - Style::default().fg(Color::Yellow) - } else { - Style::default() - } - } - _ => Style::default(), - }) .alignment(Alignment::Center); f.render_widget(question, chunks[1]); @@ -83,15 +67,15 @@ pub fn ask<B: Backend>( .style(match state.answer { Answer::Write => Style::default(), Answer::Difficulty { difficulty: _ } => { - if is_correct(&state.input, &card.responses) { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::Red) + match check_response(&state.input, &card.responses) { + CheckResponse::Correct { phonetics: _ } => { + Style::default().fg(Color::Green) + } + CheckResponse::Incorrect => Style::default().fg(Color::Red), } } }) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).title("Réponse")) .wrap(Wrap { trim: true }); f.render_widget(answer, chunks[2]); @@ -99,12 +83,17 @@ pub fn ask<B: Backend>( difficulty: selected, } = state.answer { - if !is_correct(&state.input, &card.responses) { - let paragraph = Paragraph::new(util::center_vertically( - chunks[3], - &serialization::words_to_line(&card.responses), - )) - .alignment(Alignment::Center); + let maybe_indication: Option<String> = + match check_response(&state.input, &card.responses) { + CheckResponse::Correct { phonetics } => phonetics, + CheckResponse::Incorrect => { + Some(serialization::words_to_line(&card.responses)) + } + }; + + if let Some(indication) = maybe_indication { + let paragraph = Paragraph::new(util::center_vertically(chunks[3], &indication)) + .alignment(Alignment::Center); f.render_widget(paragraph, chunks[3]); }; @@ -131,73 +120,84 @@ pub fn ask<B: Backend>( }) .collect::<Vec<Vec<Span>>>() .concat(); - let p = Paragraph::new(Text::from(Spans::from(tabs))).alignment(Alignment::Center); + let p = Paragraph::new(Text::from(Line::from(tabs))).alignment(Alignment::Center); f.render_widget(p, chunks[4]); } })?; - if let Event::Input(key) = events.next()? { + if let Event::Key(key) = event::read()? { match state.answer { - Answer::Write => match key { - Key::Char('\n') => { - let difficulty = if is_correct(&state.input, &card.responses) { + Answer::Write => match key.code { + KeyCode::Enter => { + let difficulty = if state.input.is_empty() { + // Encourage solving without typing by defaulting to good answer Difficulty::Good } else { - Difficulty::Again + match check_response(&state.input, &card.responses) { + CheckResponse::Correct { phonetics: _ } => Difficulty::Good, + CheckResponse::Incorrect => Difficulty::Again, + } }; state.answer = Answer::Difficulty { difficulty } } - Key::Char(c) => { - state.input.push(c); - if is_correct(&state.input, &card.responses) { - state.answer = Answer::Difficulty { - difficulty: Difficulty::Good, + KeyCode::Char(c) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + if c == 'u' { + state.input.clear(); + } else if c == 'w' { + let mut words = + state.input.split_whitespace().collect::<Vec<&str>>(); + if !words.is_empty() { + words.truncate(words.len() - 1); + let joined_words = words.join(" "); + let space = if !words.is_empty() { " " } else { "" }; + state.input = format!("{joined_words}{space}"); + } + } else if c == 'c' { + return Ok(Response::Aborted); + } + } else { + state.input.push(c); + if let CheckResponse::Correct { phonetics: _ } = + check_response(&state.input, &card.responses) + { + state.answer = Answer::Difficulty { + difficulty: Difficulty::Good, + } } } } - Key::Backspace => { + KeyCode::Backspace => { state.input.pop(); } - Key::Ctrl('u') => { - state.input.clear(); - } - Key::Ctrl('w') => { - let mut words = state.input.split_whitespace().collect::<Vec<&str>>(); - if !words.is_empty() { - words.truncate(words.len() - 1); - state.input = format!( - "{}{}", - words.join(" "), - if !words.is_empty() { " " } else { "" } - ); - } - } - Key::Ctrl('c') => { - return Ok(Response::Aborted); - } _ => {} }, Answer::Difficulty { difficulty: selected, - } => match key { - Key::Left => { - for d in relative_element(&card.state.difficulties(), &selected, -1).iter() + } => match key.code { + KeyCode::Left => { + if let Some(difficulty) = + relative_element(&card.state.difficulties(), &selected, -1) { - state.answer = Answer::Difficulty { difficulty: *d } + state.answer = Answer::Difficulty { difficulty } } } - Key::Right => { - for d in relative_element(&card.state.difficulties(), &selected, 1).iter() { - state.answer = Answer::Difficulty { difficulty: *d } + KeyCode::Right => { + if let Some(difficulty) = + relative_element(&card.state.difficulties(), &selected, 1) + { + state.answer = Answer::Difficulty { difficulty } } } - Key::Char('\n') => { + KeyCode::Enter => { return Ok(Response::Answered { difficulty: selected, }) } - Key::Ctrl('c') => { - return Ok(Response::Aborted); + KeyCode::Char('c') => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(Response::Aborted); + } } _ => {} }, @@ -206,18 +206,50 @@ pub fn ask<B: Backend>( } } -fn is_correct(input: &str, responses: &[String]) -> bool { - // Remove whitespaces - let input = input +enum CheckResponse { + Incorrect, + Correct { phonetics: Option<String> }, +} + +fn check_response(input: &str, responses: &[String]) -> CheckResponse { + let input = remove_whitespaces(input); + + responses + .iter() + .find(|r| remove_indications_and_phonetics(r) == input) + .map(|r| CheckResponse::Correct { + phonetics: extract_phonetics(r), + }) + .unwrap_or(CheckResponse::Incorrect) +} + +fn remove_whitespaces(input: &str) -> String { + input .split_whitespace() .map(|word| word.trim()) .collect::<Vec<&str>>() - .join(" "); + .join(" ") +} - responses - .iter() - .map(|r| r.split('(').collect::<Vec<&str>>()[0].trim()) - .any(|x| x == input) +fn remove_indications_and_phonetics(response: &str) -> &str { + response + .split(|c| c == '(' || c == '[') + .collect::<Vec<&str>>()[0] + .trim() +} + +fn extract_phonetics(response: &str) -> Option<String> { + let s1 = response.split('[').collect::<Vec<&str>>(); + if s1.len() == 2 { + let s2 = s1[1].split(']').collect::<Vec<&str>>(); + if s2.len() > 1 { + Some(format!("[{}]", s2[0])) + } else { + None + } + } else { + None + } } fn relative_element<T: Clone + PartialEq>(xs: &[T], x: &T, ri: i32) -> Option<T> { diff --git a/src/main.rs b/src/main.rs index a791f29..b18cb1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,35 +6,49 @@ mod space_repetition; mod sync; mod util; -use crate::util::event::Events; use anyhow::Result; +use clap::Parser; use std::path::PathBuf; -use structopt::StructOpt; -#[derive(StructOpt)] -#[structopt()] +#[derive(Parser)] +#[clap()] struct Opt { - #[structopt(long, default_value = "deck.deck")] + /// Path to the deck + #[clap(long, default_value = "deck.deck")] deck: String, + + /// Hide remaining card counts + #[clap(long)] + hide_remaining: bool, } fn main() -> Result<()> { - let deck_path = Opt::from_args().deck; + let args = Opt::parse(); + let deck_path = args.deck; let mut conn = db::init(db_path(&deck_path))?; let deck_name = deck::pp_from_path(&deck_path).unwrap_or_else(|| "Deck".to_string()); sync::run(&mut conn, &deck_path)?; + let deck_last_sync = util::time::seconds_since_unix_epoch()?; + + let mut term = gui::setup_terminal()?; - let mut term = gui::terminal()?; - let events = Events::new(); - match gui::start(&conn, &mut term, &events, &deck_name) { - Ok(()) => Ok(()), + match gui::start( + &mut conn, + &mut term, + &deck_path, + &deck_name, + deck_last_sync, + args.hide_remaining, + ) { + Ok(()) => (), Err(msg) => { // Show errors in TUI, otherwise they are hidden - gui::message::show(&mut term, &events, &deck_name, &format!("{msg}"), true)?; - Err(msg) + gui::message::show(&mut term, &deck_name, &format!("{msg}"), true)? } } + + gui::restore_terminal(&mut term) } fn db_path(deck_path: &str) -> String { diff --git a/src/model/difficulty.rs b/src/model/difficulty.rs index ea5a9ce..727ce4b 100644 --- a/src/model/difficulty.rs +++ b/src/model/difficulty.rs @@ -1,4 +1,4 @@ -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Difficulty { Again, Hard, diff --git a/src/model/mod.rs b/src/model/mod.rs index 2dc1ab5..4df4c49 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -2,7 +2,7 @@ use crate::space_repetition; pub mod difficulty; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Line { pub part_1: Vec<String>, pub part_2: Vec<String>, diff --git a/src/sync.rs b/src/sync.rs index 3911d55..6e3d84b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -4,11 +4,12 @@ use crate::{ }; use anyhow::Result; use rusqlite::Connection; +use std::collections::HashMap; use std::collections::HashSet; pub fn run(conn: &mut Connection, deck_path: &str) -> Result<()> { let db_entries = db::all(conn)?; - let lines = deck::read(deck_path)?; + let lines = deck::read_file(deck_path)?; let Diff { new, deleted, @@ -29,29 +30,23 @@ struct Diff { } fn diff(db_entries: Vec<DbEntry>, lines: Vec<Line>) -> Diff { - let mut file_questions: HashSet<Question> = HashSet::new(); - let mut db_questions_not_deleted: HashSet<Question> = HashSet::new(); - let mut db_questions_deleted: HashSet<Question> = HashSet::new(); + let mut file_questions = HashMap::<String, Vec<String>>::new(); + let mut db_questions_not_deleted = HashSet::<Question>::new(); + let mut db_questions_deleted = HashSet::<Question>::new(); for Line { part_1, part_2 } in lines { - for question in part_1.clone() { - let mut responses = part_2.clone(); - responses.sort(); - file_questions.insert(Question { - question, - responses, - }); - } - for question in part_2 { - let mut responses = part_1.clone(); - responses.sort(); - file_questions.insert(Question { - question, - responses, - }); - } + insert(&mut file_questions, part_1.clone(), part_2.clone()); + insert(&mut file_questions, part_2, part_1); } + let file_questions: HashSet<Question> = file_questions + .iter() + .map(|(question, responses)| Question { + question: question.to_string(), + responses: responses.to_vec(), + }) + .collect(); + for DbEntry { question, mut responses, @@ -97,77 +92,120 @@ fn diff(db_entries: Vec<DbEntry>, lines: Vec<Line>) -> Diff { } } +fn insert(map: &mut HashMap<String, Vec<String>>, questions: Vec<String>, responses: Vec<String>) { + for question in questions { + let mut responses = responses.clone(); + responses.sort(); + match map.get_mut(&question) { + Some(existing_responses) => existing_responses.append(&mut responses), + None => { + map.insert(question, responses); + } + }; + } +} + #[cfg(test)] mod tests { - use super::*; + use super::{deck, DbEntry, Diff, Question}; + use std::collections::HashSet; #[test] - fn sync() { - let db_entries = vec![ - DbEntry { - question: "A".to_string(), - responses: vec!["A".to_string()], - deleted: None, - }, - DbEntry { - question: "B".to_string(), - responses: vec!["B".to_string()], - deleted: None, - }, - DbEntry { - question: "C".to_string(), - responses: vec!["C".to_string()], - deleted: Some(0), - }, - DbEntry { - question: "D".to_string(), - responses: vec!["D".to_string()], - deleted: Some(0), - }, - ]; - - let lines = vec![ - Line { - part_1: vec!["A".to_string()], - part_2: vec!["A".to_string()], - }, - Line { - part_1: vec!["C".to_string()], - part_2: vec!["C".to_string()], - }, - Line { - part_1: vec!["E".to_string()], - part_2: vec!["E".to_string()], - }, - ]; - - let Res { - new, - deleted, - undeleted, - } = sync(db_entries, lines); + fn test_added() { + let diff = deck_diff("- A : a", "- A : a\n- B : b"); - assert_eq!( - new, - vec!(Question { - question: "E".to_string(), - responses: vec!("E".to_string()) - }) - ); - assert_eq!( - deleted, - vec!(Question { - question: "B".to_string(), - responses: vec!("B".to_string()) - }) + has_questions(diff.new, vec![("B", vec!["b"]), ("b", vec!["B"])]); + assert!(diff.deleted.is_empty()); + assert!(diff.undeleted.is_empty()); + } + + #[test] + fn test_updated() { + let diff = deck_diff("- A : a1", "- A : a2"); + + has_questions(diff.new, vec![("A", vec!["a2"]), ("a2", vec!["A"])]); + has_questions(diff.deleted, vec![("A", vec!["a1"]), ("a1", vec!["A"])]); + assert!(diff.undeleted.is_empty()); + } + + #[test] + fn test_deleted() { + let diff = deck_diff("- A : a", ""); + + assert!(diff.new.is_empty()); + has_questions(diff.deleted, vec![("A", vec!["a"]), ("a", vec!["A"])]); + assert!(diff.undeleted.is_empty()); + } + + #[test] + fn test_undeleted() { + let db_entries = vec![DbEntry { + question: "A".to_string(), + responses: vec!["a".to_string()], + deleted: Some(0), + }]; + + let diff = super::diff(db_entries, deck::tests::read_string("- A : a").unwrap()); + + has_questions(diff.new, vec![("a", vec!["A"])]); + assert!(diff.deleted.is_empty()); + has_questions(diff.undeleted, vec![("A", vec!["a"])]); + } + #[test] + fn regroup_same_question() { + let diff = deck_diff("", "- A : a\n- A | B : b"); + + has_questions( + diff.new, + vec![ + ("A", vec!["a", "b"]), + ("B", vec!["b"]), + ("a", vec!["A"]), + ("b", vec!["A", "B"]), + ], ); + assert!(diff.deleted.is_empty()); + assert!(diff.undeleted.is_empty()); + } + + fn deck_diff(from: &str, to: &str) -> Diff { + super::diff(db_entries(from), deck::tests::read_string(to).unwrap()) + } + + fn has_questions(questions: Vec<Question>, xs: Vec<(&str, Vec<&str>)>) { assert_eq!( - undeleted, - vec!(Question { - question: "C".to_string(), - responses: vec!("C".to_string()) - }) - ); + to_set(questions), + HashSet::from_iter( + xs.iter() + .map(|(y, ys)| Question { + question: y.to_string(), + responses: ys.iter().map(|z| z.to_string()).collect::<Vec<_>>() + }) + .collect::<Vec<_>>() + ) + ) + } + + fn to_set<A: std::cmp::Eq + std::hash::Hash + std::clone::Clone>(xs: Vec<A>) -> HashSet<A> { + xs.iter().cloned().collect() + } + + fn db_entries(deck: &str) -> Vec<DbEntry> { + let lines = deck::tests::read_string(deck).unwrap(); + let diff = super::diff(vec![], lines); + diff.new + .iter() + .map( + |Question { + question, + responses, + }| DbEntry { + question: question.to_string(), + responses: responses.to_vec(), + deleted: None, + }, + ) + .collect() } } diff --git a/src/util/event.rs b/src/util/event.rs deleted file mode 100644 index 379df99..0000000 --- a/src/util/event.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::io; -use std::sync::mpsc; -use std::thread; -use std::time::Duration; - -use termion::event::Key; -use termion::input::TermRead; - -pub enum Event<I> { - Input(I), - Tick, -} - -/// A small event handler that wrap termion input and tick events. Each event -/// type is handled in its own thread and returned to a common `Receiver` -pub struct Events { - rx: mpsc::Receiver<Event<Key>>, - input_handle: thread::JoinHandle<()>, - tick_handle: thread::JoinHandle<()>, -} - -#[derive(Debug, Clone, Copy)] -pub struct Config { - pub tick_rate: Duration, -} - -impl Default for Config { - fn default() -> Config { - Config { - tick_rate: Duration::from_millis(250), - } - } -} - -impl Events { - pub fn new() -> Events { - Events::with_config(Config::default()) - } - - pub fn with_config(config: Config) -> Events { - let (tx, rx) = mpsc::channel(); - let input_handle = { - let tx = tx.clone(); - thread::spawn(move || { - let stdin = io::stdin(); - for key in stdin.keys().flatten() { - if let Err(err) = tx.send(Event::Input(key)) { - eprintln!("{err}"); - return; - } - } - }) - }; - let tick_handle = { - thread::spawn(move || loop { - if let Err(err) = tx.send(Event::Tick) { - eprintln!("{err}"); - break; - } - thread::sleep(config.tick_rate); - }) - }; - Events { - rx, - input_handle, - tick_handle, - } - } - - pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> { - self.rx.recv() - } -} diff --git a/src/util/mod.rs b/src/util/mod.rs index c866e61..3444389 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,4 +1,3 @@ #[allow(dead_code)] -pub mod event; pub mod serialization; pub mod time; diff --git a/src/util/serialization.rs b/src/util/serialization.rs index 61b3a83..189a41a 100644 --- a/src/util/serialization.rs +++ b/src/util/serialization.rs @@ -8,3 +8,21 @@ pub fn line_to_words(line: &str) -> Vec<String> { pub fn words_to_line(words: &[String]) -> String { words.join(" | ") } + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_line_to_words() { + assert_eq!(line_to_words("a"), vec!("a")); + assert_eq!(line_to_words("a | b | c"), vec!("a", "b", "c")); + } + + #[test] + fn test_words_to_line() { + assert_eq!(words_to_line(&["a".to_string()]), "a"); + assert_eq!(words_to_line(&["a".to_string(), "b".to_string(), "c".to_string()]), "a | b | c"); + } +} |