diff --git a/src/db/connection.rs b/src/db/connection.rs index 5940b5f..16e2bd0 100644 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -409,3 +409,210 @@ fn quote_conn_value(value: &str) -> String { let escaped = value.replace('\\', "\\\\").replace('\'', "\\'"); format!("'{}'", escaped) } + +#[cfg(test)] +mod tests { + use super::*; + + // --- ConnectionConfig --- + + #[test] + fn test_default_config() { + let config = ConnectionConfig::default(); + assert_eq!(config.host, "localhost"); + assert_eq!(config.port, 5432); + assert_eq!(config.database, "postgres"); + assert_eq!(config.username, "postgres"); + assert!(config.password.is_empty()); + assert_eq!(config.ssl_mode, SslMode::Prefer); + assert!(!config.accept_invalid_certs); + assert!(config.ca_cert_path.is_none()); + assert!(!config.use_aws_rds_certs); + } + + #[test] + fn test_connection_string() { + let config = ConnectionConfig { + host: "myhost".into(), + port: 5433, + database: "mydb".into(), + username: "myuser".into(), + password: "mypass".into(), + ssl_mode: SslMode::Require, + ..Default::default() + }; + let s = config.connection_string(); + assert!(s.contains("host='myhost'")); + assert!(s.contains("port=5433")); + assert!(s.contains("dbname='mydb'")); + assert!(s.contains("user='myuser'")); + assert!(s.contains("password='mypass'")); + assert!(s.contains("sslmode=require")); + } + + #[test] + fn test_connection_string_ssl_modes() { + let modes = vec![ + (SslMode::Disable, "disable"), + (SslMode::Prefer, "prefer"), + (SslMode::Require, "require"), + (SslMode::VerifyCa, "verify-ca"), + (SslMode::VerifyFull, "verify-full"), + ]; + for (mode, expected) in modes { + let config = ConnectionConfig { + ssl_mode: mode, + ..Default::default() + }; + assert!( + config + .connection_string() + .contains(&format!("sslmode={}", expected)), + "Expected sslmode={} for {:?}", + expected, + mode + ); + } + } + + #[test] + fn test_display_string() { + let config = ConnectionConfig { + username: "admin".into(), + host: "db.example.com".into(), + port: 5432, + database: "production".into(), + ..Default::default() + }; + assert_eq!( + config.display_string(), + "admin@db.example.com:5432/production" + ); + } + + #[test] + fn test_connection_string_escaping() { + let config = ConnectionConfig { + password: "pass'word\\test".into(), + ..Default::default() + }; + let s = config.connection_string(); + assert!(s.contains("pass\\'word\\\\test")); + } + + // --- AWS RDS detection --- + + #[test] + fn test_is_aws_rds_host() { + let config = ConnectionConfig { + host: "mydb.abc123.us-east-1.rds.amazonaws.com".into(), + ..Default::default() + }; + assert!(config.is_aws_rds_host()); + } + + #[test] + fn test_is_not_aws_rds_host() { + let config = ConnectionConfig { + host: "localhost".into(), + ..Default::default() + }; + assert!(!config.is_aws_rds_host()); + } + + #[test] + fn test_should_use_aws_rds_certs_auto() { + let config = ConnectionConfig { + host: "mydb.abc123.us-east-1.rds.amazonaws.com".into(), + ..Default::default() + }; + assert!(config.should_use_aws_rds_certs()); + } + + #[test] + fn test_should_use_aws_rds_certs_explicit() { + let config = ConnectionConfig { + host: "custom-proxy.example.com".into(), + use_aws_rds_certs: true, + ..Default::default() + }; + assert!(config.should_use_aws_rds_certs()); + } + + // --- ConnectionManager --- + + #[test] + fn test_new_manager() { + let mgr = ConnectionManager::new(); + assert!(!mgr.is_connected()); + assert_eq!(mgr.current_database, "postgres"); + assert_eq!(mgr.current_schema, "public"); + } + + // --- SSL mode default --- + + #[test] + fn test_ssl_mode_default() { + let mode = SslMode::default(); + assert_eq!(mode, SslMode::Prefer); + } + + // --- quote_conn_value --- + + #[test] + fn test_quote_simple() { + assert_eq!(quote_conn_value("hello"), "'hello'"); + } + + #[test] + fn test_quote_with_special_chars() { + assert_eq!(quote_conn_value("it's"), "'it\\'s'"); + assert_eq!(quote_conn_value("back\\slash"), "'back\\\\slash'"); + } + + // --- base64 decoder --- + + #[test] + fn test_base64_decode() { + let decoded = base64_decode("SGVsbG8gV29ybGQ=").unwrap(); + assert_eq!(String::from_utf8(decoded).unwrap(), "Hello World"); + } + + #[test] + fn test_base64_decode_no_padding() { + let decoded = base64_decode("SGVsbG8").unwrap(); + assert_eq!(String::from_utf8(decoded).unwrap(), "Hello"); + } + + #[test] + fn test_base64_decode_with_newlines() { + let decoded = base64_decode("SGVs\nbG8=").unwrap(); + assert_eq!(String::from_utf8(decoded).unwrap(), "Hello"); + } + + // --- Serialization --- + + #[test] + fn test_config_serialization() { + let config = ConnectionConfig::default(); + let toml_str = toml::to_string(&config).unwrap(); + assert!(toml_str.contains("host")); + assert!(!toml_str.contains("password")); // password is skip_serializing + } + + #[test] + fn test_config_deserialization() { + let toml_str = r#" + name = "Test" + host = "localhost" + port = 5432 + database = "testdb" + username = "user" + ssl_mode = "Require" + "#; + let config: ConnectionConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.name, "Test"); + assert_eq!(config.ssl_mode, SslMode::Require); + assert!(config.password.is_empty()); + } +} diff --git a/src/db/query.rs b/src/db/query.rs index 4f6d86d..7ba01f9 100644 --- a/src/db/query.rs +++ b/src/db/query.rs @@ -201,6 +201,99 @@ fn parse_rows(rows: &[Row], execution_time: Duration) -> QueryResult { } } +#[cfg(test)] +mod tests { + use super::*; + + // --- CellValue display --- + + #[test] + fn test_null_display() { + assert_eq!(CellValue::Null.display(), "NULL"); + } + + #[test] + fn test_bool_display() { + assert_eq!(CellValue::Bool(true).display(), "true"); + assert_eq!(CellValue::Bool(false).display(), "false"); + } + + #[test] + fn test_integer_display() { + assert_eq!(CellValue::Int16(42).display(), "42"); + assert_eq!(CellValue::Int32(-100).display(), "-100"); + assert_eq!(CellValue::Int64(9_999_999).display(), "9999999"); + } + + #[test] + fn test_float_display() { + assert_eq!(CellValue::Float32(3.14).display(), "3.14"); + assert_eq!(CellValue::Float64(2.718).display(), "2.718"); + } + + #[test] + fn test_text_display() { + assert_eq!(CellValue::Text("hello".into()).display(), "hello"); + } + + #[test] + fn test_bytes_display() { + assert_eq!(CellValue::Bytes(vec![1, 2, 3]).display(), "[3 bytes]"); + } + + #[test] + fn test_json_display() { + let val = serde_json::json!({"key": "value"}); + let display = CellValue::Json(val).display(); + assert!(display.contains("key")); + assert!(display.contains("value")); + } + + #[test] + fn test_array_display() { + let arr = CellValue::Array(vec![ + CellValue::Int32(1), + CellValue::Int32(2), + CellValue::Int32(3), + ]); + assert_eq!(arr.display(), "{1, 2, 3}"); + } + + #[test] + fn test_unknown_display() { + assert_eq!(CellValue::Unknown("raw".into()).display(), "raw"); + } + + // --- CellValue display_width --- + + #[test] + fn test_display_width() { + assert_eq!(CellValue::Null.display_width(), 4); // "NULL" + assert_eq!(CellValue::Text("hello".into()).display_width(), 5); + assert_eq!(CellValue::Int32(100).display_width(), 3); + } + + // --- QueryResult --- + + #[test] + fn test_empty_result() { + let r = QueryResult::empty(); + assert!(r.columns.is_empty()); + assert!(r.rows.is_empty()); + assert_eq!(r.row_count, 0); + assert!(r.error.is_none()); + assert!(r.affected_rows.is_none()); + } + + #[test] + fn test_error_result() { + let r = QueryResult::error("bad query".into(), Duration::from_millis(10)); + assert!(r.error.is_some()); + assert_eq!(r.error.unwrap(), "bad query"); + assert!(r.rows.is_empty()); + } +} + fn extract_value(row: &Row, idx: usize, pg_type: &Type) -> CellValue { // Try to extract based on type match *pg_type { diff --git a/src/db/schema.rs b/src/db/schema.rs index 70d919d..9d55a5b 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -74,6 +74,94 @@ pub struct IndexInfo { pub is_primary: bool, } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_table_type_icon() { + assert_eq!(TableType::Table.icon(), "󰓫"); + assert_eq!(TableType::View.icon(), "󰈈"); + assert_eq!(TableType::MaterializedView.icon(), "󰈈"); + assert_eq!(TableType::ForeignTable.icon(), "󰒍"); + } + + #[test] + fn test_table_type_label() { + assert_eq!(TableType::Table.label(), "TABLE"); + assert_eq!(TableType::View.label(), "VIEW"); + assert_eq!(TableType::MaterializedView.label(), "MVIEW"); + assert_eq!(TableType::ForeignTable.label(), "FOREIGN"); + } + + #[test] + fn test_table_type_equality() { + assert_eq!(TableType::Table, TableType::Table); + assert_ne!(TableType::Table, TableType::View); + } + + #[test] + fn test_database_info_clone() { + let db = DatabaseInfo { + name: "testdb".into(), + owner: "postgres".into(), + encoding: "UTF8".into(), + }; + let cloned = db.clone(); + assert_eq!(cloned.name, "testdb"); + } + + #[test] + fn test_schema_info_clone() { + let schema = SchemaInfo { + name: "public".into(), + owner: "postgres".into(), + }; + let cloned = schema.clone(); + assert_eq!(cloned.name, "public"); + } + + #[test] + fn test_table_info_clone() { + let table = TableInfo { + name: "users".into(), + schema: "public".into(), + table_type: TableType::Table, + row_estimate: 1000, + }; + let cloned = table.clone(); + assert_eq!(cloned.name, "users"); + assert_eq!(cloned.row_estimate, 1000); + } + + #[test] + fn test_column_details() { + let col = ColumnDetails { + name: "id".into(), + data_type: "integer".into(), + is_nullable: false, + is_primary_key: true, + default_value: Some("nextval('users_id_seq')".into()), + ordinal_position: 1, + }; + assert_eq!(col.name, "id"); + assert!(col.is_primary_key); + assert!(!col.is_nullable); + } + + #[test] + fn test_index_info() { + let idx = IndexInfo { + name: "users_pkey".into(), + columns: vec!["id".into()], + is_unique: true, + is_primary: true, + }; + assert_eq!(idx.name, "users_pkey"); + assert!(idx.is_primary); + } +} + pub async fn get_databases(client: &Client) -> Result> { let rows = client .query( diff --git a/src/editor/buffer.rs b/src/editor/buffer.rs index b665f0c..547e14c 100644 --- a/src/editor/buffer.rs +++ b/src/editor/buffer.rs @@ -390,3 +390,461 @@ impl TextBuffer { self.lines.len() } } + +#[cfg(test)] +mod tests { + use super::*; + + // --- Construction --- + + #[test] + fn test_new_buffer() { + let buf = TextBuffer::new(); + assert_eq!(buf.lines, vec![""]); + assert_eq!(buf.cursor_x, 0); + assert_eq!(buf.cursor_y, 0); + assert!(buf.selection_start.is_none()); + assert!(!buf.modified); + } + + #[test] + fn test_default_buffer() { + let buf = TextBuffer::default(); + assert_eq!(buf.lines, vec![""]); + } + + #[test] + fn test_from_text_single_line() { + let buf = TextBuffer::from_text("hello"); + assert_eq!(buf.lines, vec!["hello"]); + assert_eq!(buf.cursor_x, 0); + assert_eq!(buf.cursor_y, 0); + } + + #[test] + fn test_from_text_multi_line() { + let buf = TextBuffer::from_text("line1\nline2\nline3"); + assert_eq!(buf.lines, vec!["line1", "line2", "line3"]); + } + + #[test] + fn test_from_text_empty() { + let buf = TextBuffer::from_text(""); + assert_eq!(buf.lines, vec![""]); + } + + // --- Text retrieval --- + + #[test] + fn test_text_round_trip() { + let original = "SELECT *\nFROM users\nWHERE id = 1"; + let buf = TextBuffer::from_text(original); + assert_eq!(buf.text(), original); + } + + #[test] + fn test_current_line() { + let buf = TextBuffer::from_text("line1\nline2"); + assert_eq!(buf.current_line(), "line1"); + } + + #[test] + fn test_line_count() { + let buf = TextBuffer::from_text("a\nb\nc"); + assert_eq!(buf.line_count(), 3); + } + + // --- Insertion --- + + #[test] + fn test_insert_char() { + let mut buf = TextBuffer::new(); + buf.insert_char('a'); + assert_eq!(buf.text(), "a"); + assert_eq!(buf.cursor_x, 1); + assert!(buf.modified); + } + + #[test] + fn test_insert_char_middle_of_line() { + let mut buf = TextBuffer::from_text("ac"); + buf.cursor_x = 1; + buf.insert_char('b'); + assert_eq!(buf.text(), "abc"); + assert_eq!(buf.cursor_x, 2); + } + + #[test] + fn test_insert_newline() { + let mut buf = TextBuffer::from_text("hello world"); + buf.cursor_x = 5; + buf.insert_newline(); + assert_eq!(buf.lines, vec!["hello", " world"]); + assert_eq!(buf.cursor_y, 1); + assert_eq!(buf.cursor_x, 0); + } + + #[test] + fn test_insert_text_multiline() { + let mut buf = TextBuffer::new(); + buf.insert_text("hello\nworld"); + assert_eq!(buf.lines, vec!["hello", "world"]); + assert_eq!(buf.cursor_y, 1); + assert_eq!(buf.cursor_x, 5); + } + + #[test] + fn test_insert_tab() { + let mut buf = TextBuffer::new(); + buf.insert_tab(); + assert_eq!(buf.text(), " "); + assert_eq!(buf.cursor_x, 4); + } + + // --- Deletion --- + + #[test] + fn test_backspace_middle() { + let mut buf = TextBuffer::from_text("abc"); + buf.cursor_x = 2; + buf.backspace(); + assert_eq!(buf.text(), "ac"); + assert_eq!(buf.cursor_x, 1); + } + + #[test] + fn test_backspace_at_start_merges_lines() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.cursor_y = 1; + buf.cursor_x = 0; + buf.backspace(); + assert_eq!(buf.lines, vec!["line1line2"]); + assert_eq!(buf.cursor_y, 0); + assert_eq!(buf.cursor_x, 5); + } + + #[test] + fn test_backspace_at_beginning_does_nothing() { + let mut buf = TextBuffer::new(); + buf.backspace(); + assert_eq!(buf.text(), ""); + } + + #[test] + fn test_delete_middle() { + let mut buf = TextBuffer::from_text("abc"); + buf.cursor_x = 1; + buf.delete(); + assert_eq!(buf.text(), "ac"); + } + + #[test] + fn test_delete_at_end_merges_lines() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.cursor_x = 5; + buf.delete(); + assert_eq!(buf.lines, vec!["line1line2"]); + } + + #[test] + fn test_delete_at_end_of_last_line_does_nothing() { + let mut buf = TextBuffer::from_text("hello"); + buf.cursor_x = 5; + buf.delete(); + assert_eq!(buf.text(), "hello"); + } + + // --- Cursor movement --- + + #[test] + fn test_move_left() { + let mut buf = TextBuffer::from_text("hello"); + buf.cursor_x = 3; + buf.move_left(); + assert_eq!(buf.cursor_x, 2); + } + + #[test] + fn test_move_left_wraps_to_previous_line() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.cursor_y = 1; + buf.cursor_x = 0; + buf.move_left(); + assert_eq!(buf.cursor_y, 0); + assert_eq!(buf.cursor_x, 5); + } + + #[test] + fn test_move_right() { + let mut buf = TextBuffer::from_text("hello"); + buf.cursor_x = 2; + buf.move_right(); + assert_eq!(buf.cursor_x, 3); + } + + #[test] + fn test_move_right_wraps_to_next_line() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.cursor_x = 5; + buf.move_right(); + assert_eq!(buf.cursor_y, 1); + assert_eq!(buf.cursor_x, 0); + } + + #[test] + fn test_move_up() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.cursor_y = 1; + buf.cursor_x = 3; + buf.move_up(); + assert_eq!(buf.cursor_y, 0); + assert_eq!(buf.cursor_x, 3); + } + + #[test] + fn test_move_up_clamps_cursor_x() { + let mut buf = TextBuffer::from_text("hi\nlong line"); + buf.cursor_y = 1; + buf.cursor_x = 8; + buf.move_up(); + assert_eq!(buf.cursor_y, 0); + assert_eq!(buf.cursor_x, 2); // clamped to length of "hi" + } + + #[test] + fn test_move_down() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.move_down(); + assert_eq!(buf.cursor_y, 1); + } + + #[test] + fn test_move_to_line_start() { + let mut buf = TextBuffer::from_text("hello"); + buf.cursor_x = 3; + buf.move_to_line_start(); + assert_eq!(buf.cursor_x, 0); + } + + #[test] + fn test_move_to_line_end() { + let mut buf = TextBuffer::from_text("hello"); + buf.move_to_line_end(); + assert_eq!(buf.cursor_x, 5); + } + + #[test] + fn test_move_to_start() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.cursor_y = 1; + buf.cursor_x = 3; + buf.move_to_start(); + assert_eq!(buf.cursor_y, 0); + assert_eq!(buf.cursor_x, 0); + } + + #[test] + fn test_move_to_end() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.move_to_end(); + assert_eq!(buf.cursor_y, 1); + assert_eq!(buf.cursor_x, 5); + } + + // --- Word movement --- + + #[test] + fn test_move_word_left() { + let mut buf = TextBuffer::from_text("hello world"); + buf.cursor_x = 11; + buf.move_word_left(); + assert_eq!(buf.cursor_x, 6); + buf.move_word_left(); + assert_eq!(buf.cursor_x, 0); + } + + #[test] + fn test_move_word_right() { + let mut buf = TextBuffer::from_text("hello world"); + buf.move_word_right(); + assert_eq!(buf.cursor_x, 6); + buf.move_word_right(); + assert_eq!(buf.cursor_x, 11); + } + + #[test] + fn test_move_word_left_across_lines() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.cursor_y = 1; + buf.cursor_x = 0; + buf.move_word_left(); + assert_eq!(buf.cursor_y, 0); + assert_eq!(buf.cursor_x, 5); + } + + #[test] + fn test_move_word_right_across_lines() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.cursor_x = 5; + buf.move_word_right(); + assert_eq!(buf.cursor_y, 1); + assert_eq!(buf.cursor_x, 0); + } + + // --- Selection --- + + #[test] + fn test_selection_start_and_get() { + let mut buf = TextBuffer::from_text("hello world"); + buf.cursor_x = 2; + buf.start_selection(); + buf.cursor_x = 7; + let sel = buf.get_selection().unwrap(); + assert_eq!(sel, ((2, 0), (7, 0))); + } + + #[test] + fn test_get_selected_text_same_line() { + let mut buf = TextBuffer::from_text("hello world"); + buf.cursor_x = 0; + buf.start_selection(); + buf.cursor_x = 5; + assert_eq!(buf.get_selected_text().unwrap(), "hello"); + } + + #[test] + fn test_get_selected_text_multi_line() { + let mut buf = TextBuffer::from_text("line1\nline2\nline3"); + buf.cursor_x = 3; + buf.cursor_y = 0; + buf.start_selection(); + buf.cursor_y = 2; + buf.cursor_x = 2; + let text = buf.get_selected_text().unwrap(); + assert_eq!(text, "e1\nline2\nli"); + } + + #[test] + fn test_select_all() { + let mut buf = TextBuffer::from_text("line1\nline2"); + buf.select_all(); + assert_eq!(buf.selection_start, Some((0, 0))); + assert_eq!(buf.cursor_y, 1); + assert_eq!(buf.cursor_x, 5); + } + + #[test] + fn test_select_line() { + let mut buf = TextBuffer::from_text("hello world"); + buf.select_line(); + assert_eq!(buf.selection_start, Some((0, 0))); + assert_eq!(buf.cursor_x, 11); + } + + #[test] + fn test_delete_selection_same_line() { + let mut buf = TextBuffer::from_text("hello world"); + buf.cursor_x = 0; + buf.start_selection(); + buf.cursor_x = 6; + buf.delete_selection(); + assert_eq!(buf.text(), "world"); + assert_eq!(buf.cursor_x, 0); + } + + #[test] + fn test_delete_selection_multi_line() { + let mut buf = TextBuffer::from_text("line1\nline2\nline3"); + buf.cursor_x = 3; + buf.cursor_y = 0; + buf.start_selection(); + buf.cursor_y = 2; + buf.cursor_x = 3; + buf.delete_selection(); + assert_eq!(buf.text(), "line3"); + } + + #[test] + fn test_clear_selection() { + let mut buf = TextBuffer::new(); + buf.start_selection(); + assert!(buf.has_selection()); + buf.clear_selection(); + assert!(!buf.has_selection()); + } + + // --- Clear / Set --- + + #[test] + fn test_clear() { + let mut buf = TextBuffer::from_text("something"); + buf.cursor_x = 5; + buf.modified = true; + buf.clear(); + assert_eq!(buf.text(), ""); + assert_eq!(buf.cursor_x, 0); + assert_eq!(buf.cursor_y, 0); + assert!(!buf.modified); + } + + #[test] + fn test_set_text() { + let mut buf = TextBuffer::from_text("old"); + buf.set_text("new\ncontent"); + assert_eq!(buf.lines, vec!["new", "content"]); + assert_eq!(buf.cursor_x, 0); + assert_eq!(buf.cursor_y, 0); + assert!(!buf.modified); + } + + // --- Scroll --- + + #[test] + fn test_ensure_cursor_visible_scrolls_down() { + let mut buf = TextBuffer::from_text("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); + buf.cursor_y = 8; + buf.ensure_cursor_visible(5); + assert_eq!(buf.scroll_offset, 4); + } + + #[test] + fn test_ensure_cursor_visible_scrolls_up() { + let mut buf = TextBuffer::from_text("1\n2\n3\n4\n5"); + buf.scroll_offset = 3; + buf.cursor_y = 1; + buf.ensure_cursor_visible(5); + assert_eq!(buf.scroll_offset, 1); + } + + // --- Edge cases --- + + #[test] + fn test_backspace_with_selection() { + let mut buf = TextBuffer::from_text("hello world"); + buf.cursor_x = 0; + buf.start_selection(); + buf.cursor_x = 5; + buf.backspace(); + assert_eq!(buf.text(), " world"); + } + + #[test] + fn test_delete_with_selection() { + let mut buf = TextBuffer::from_text("hello world"); + buf.cursor_x = 6; + buf.start_selection(); + buf.cursor_x = 11; + buf.delete(); + assert_eq!(buf.text(), "hello "); + } + + #[test] + fn test_insert_char_replaces_selection() { + let mut buf = TextBuffer::from_text("hello"); + buf.cursor_x = 0; + buf.start_selection(); + buf.cursor_x = 5; + buf.insert_char('X'); + assert_eq!(buf.text(), "X"); + } +} diff --git a/src/editor/history.rs b/src/editor/history.rs index aee69d2..33f5519 100644 --- a/src/editor/history.rs +++ b/src/editor/history.rs @@ -120,3 +120,209 @@ impl QueryHistory { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn make_entry(query: &str) -> HistoryEntry { + HistoryEntry { + query: query.to_string(), + timestamp: Utc::now(), + database: "testdb".to_string(), + execution_time_ms: 42, + success: true, + } + } + + #[test] + fn test_new_history_is_empty() { + let h = QueryHistory::new(); + assert!(h.entries().is_empty()); + } + + #[test] + fn test_add_entry() { + let mut h = QueryHistory::new(); + h.add(make_entry("SELECT 1")); + assert_eq!(h.entries().len(), 1); + assert_eq!(h.entries()[0].query, "SELECT 1"); + } + + #[test] + fn test_no_consecutive_duplicates() { + let mut h = QueryHistory::new(); + h.add(make_entry("SELECT 1")); + h.add(make_entry("SELECT 1")); + assert_eq!(h.entries().len(), 1); + } + + #[test] + fn test_duplicate_with_whitespace_trimmed() { + let mut h = QueryHistory::new(); + h.add(make_entry("SELECT 1")); + h.add(make_entry(" SELECT 1 ")); + assert_eq!(h.entries().len(), 1); + } + + #[test] + fn test_non_consecutive_duplicates_allowed() { + let mut h = QueryHistory::new(); + h.add(make_entry("SELECT 1")); + h.add(make_entry("SELECT 2")); + h.add(make_entry("SELECT 1")); + assert_eq!(h.entries().len(), 3); + } + + #[test] + fn test_max_entries_trim() { + let mut h = QueryHistory::new(); + // Override max for testing + h.max_entries = 3; + h.add(make_entry("q1")); + h.add(make_entry("q2")); + h.add(make_entry("q3")); + h.add(make_entry("q4")); + assert_eq!(h.entries().len(), 3); + assert_eq!(h.entries()[0].query, "q2"); + } + + // --- Navigation --- + + #[test] + fn test_previous_on_empty_returns_none() { + let mut h = QueryHistory::new(); + assert!(h.previous().is_none()); + } + + #[test] + fn test_previous_returns_last_entry() { + let mut h = QueryHistory::new(); + h.add(make_entry("q1")); + h.add(make_entry("q2")); + let entry = h.previous().unwrap(); + assert_eq!(entry.query, "q2"); + } + + #[test] + fn test_previous_navigates_backwards() { + let mut h = QueryHistory::new(); + h.add(make_entry("q1")); + h.add(make_entry("q2")); + h.add(make_entry("q3")); + assert_eq!(h.previous().unwrap().query, "q3"); + assert_eq!(h.previous().unwrap().query, "q2"); + assert_eq!(h.previous().unwrap().query, "q1"); + } + + #[test] + fn test_previous_stops_at_first() { + let mut h = QueryHistory::new(); + h.add(make_entry("q1")); + h.add(make_entry("q2")); + h.previous(); // q2 + h.previous(); // q1 + let entry = h.previous().unwrap(); // still q1 + assert_eq!(entry.query, "q1"); + } + + #[test] + fn test_next_without_previous_returns_none() { + let mut h = QueryHistory::new(); + h.add(make_entry("q1")); + assert!(h.next().is_none()); + } + + #[test] + fn test_next_navigates_forward() { + let mut h = QueryHistory::new(); + h.add(make_entry("q1")); + h.add(make_entry("q2")); + h.add(make_entry("q3")); + h.previous(); // q3 + h.previous(); // q2 + h.previous(); // q1 + assert_eq!(h.next().unwrap().query, "q2"); + assert_eq!(h.next().unwrap().query, "q3"); + } + + #[test] + fn test_next_stops_at_last() { + let mut h = QueryHistory::new(); + h.add(make_entry("q1")); + h.add(make_entry("q2")); + h.previous(); // q2 + h.previous(); // q1 + h.next(); // q2 + let entry = h.next().unwrap(); // still q2 + assert_eq!(entry.query, "q2"); + } + + #[test] + fn test_reset_navigation() { + let mut h = QueryHistory::new(); + h.add(make_entry("q1")); + h.add(make_entry("q2")); + h.previous(); // q2 + h.previous(); // q1 + h.reset_navigation(); + // After reset, previous() goes to last entry again + assert_eq!(h.previous().unwrap().query, "q2"); + } + + #[test] + fn test_add_resets_navigation() { + let mut h = QueryHistory::new(); + h.add(make_entry("q1")); + h.add(make_entry("q2")); + h.previous(); // q2 + h.previous(); // q1 + h.add(make_entry("q3")); + // After adding, navigation resets + assert_eq!(h.previous().unwrap().query, "q3"); + } + + // --- Search --- + + #[test] + fn test_search() { + let mut h = QueryHistory::new(); + h.add(make_entry("SELECT * FROM users")); + h.add(make_entry("INSERT INTO logs VALUES (1)")); + h.add(make_entry("SELECT count(*) FROM users")); + + let results = h.search("select"); + assert_eq!(results.len(), 2); + } + + #[test] + fn test_search_case_insensitive() { + let mut h = QueryHistory::new(); + h.add(make_entry("SELECT * FROM users")); + let results = h.search("select"); + assert_eq!(results.len(), 1); + } + + #[test] + fn test_search_no_results() { + let mut h = QueryHistory::new(); + h.add(make_entry("SELECT 1")); + let results = h.search("UPDATE"); + assert!(results.is_empty()); + } + + // --- Serialization --- + + #[test] + fn test_serialization_round_trip() { + let mut h = QueryHistory::new(); + h.add(make_entry("SELECT 1")); + h.add(make_entry("SELECT 2")); + + let json = serde_json::to_string(&h).unwrap(); + let deserialized: QueryHistory = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.entries().len(), 2); + assert_eq!(deserialized.entries()[0].query, "SELECT 1"); + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs index fb51dfe..8651e67 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -330,3 +330,168 @@ pub fn is_sql_keyword(word: &str) -> bool { pub fn is_sql_type(word: &str) -> bool { SQL_TYPES.contains(&word.to_uppercase().as_str()) } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Color; + + // --- Theme construction --- + + #[test] + fn test_dark_theme() { + let theme = Theme::dark(); + match theme.bg_primary { + Color::Rgb(r, g, b) => assert!(r < 50 && g < 50 && b < 50), + _ => panic!("Expected RGB color"), + } + } + + #[test] + fn test_light_theme() { + let theme = Theme::light(); + match theme.bg_primary { + Color::Rgb(r, g, b) => assert!(r > 200 && g > 200 && b > 200), + _ => panic!("Expected RGB color"), + } + } + + #[test] + fn test_default_is_dark() { + let default_theme = Theme::default(); + let dark_theme = Theme::dark(); + assert_eq!(default_theme.bg_primary, dark_theme.bg_primary); + } + + // --- Style helpers --- + + #[test] + fn test_normal_style() { + let theme = Theme::dark(); + let style = theme.normal(); + assert_eq!(style.fg, Some(theme.text_primary)); + assert_eq!(style.bg, Some(theme.bg_primary)); + } + + #[test] + fn test_header_style_is_bold() { + let theme = Theme::dark(); + let style = theme.header(); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_border_style_focused() { + let theme = Theme::dark(); + let focused = theme.border_style(true); + let unfocused = theme.border_style(false); + assert_eq!(focused.fg, Some(theme.border_focused)); + assert_eq!(unfocused.fg, Some(theme.border)); + } + + #[test] + fn test_status_styles() { + let theme = Theme::dark(); + assert_eq!(theme.status_success().fg, Some(theme.success)); + assert_eq!(theme.status_error().fg, Some(theme.error)); + assert_eq!(theme.status_warning().fg, Some(theme.warning)); + } + + // --- SQL keyword detection --- + + #[test] + fn test_is_sql_keyword() { + assert!(is_sql_keyword("SELECT")); + assert!(is_sql_keyword("FROM")); + assert!(is_sql_keyword("WHERE")); + assert!(is_sql_keyword("JOIN")); + } + + #[test] + fn test_is_sql_keyword_case_insensitive() { + assert!(is_sql_keyword("select")); + assert!(is_sql_keyword("Select")); + assert!(is_sql_keyword("fRoM")); + } + + #[test] + fn test_is_not_sql_keyword() { + assert!(!is_sql_keyword("users")); + assert!(!is_sql_keyword("foo")); + assert!(!is_sql_keyword("column_name")); + } + + #[test] + fn test_keyword_categories() { + // DML + assert!(is_sql_keyword("INSERT")); + assert!(is_sql_keyword("UPDATE")); + assert!(is_sql_keyword("DELETE")); + // DDL + assert!(is_sql_keyword("CREATE")); + assert!(is_sql_keyword("ALTER")); + assert!(is_sql_keyword("DROP")); + // Window functions + assert!(is_sql_keyword("OVER")); + assert!(is_sql_keyword("PARTITION")); + assert!(is_sql_keyword("ROW_NUMBER")); + // CTEs + assert!(is_sql_keyword("WITH")); + assert!(is_sql_keyword("RECURSIVE")); + } + + // --- SQL type detection --- + + #[test] + fn test_is_sql_type() { + assert!(is_sql_type("INTEGER")); + assert!(is_sql_type("VARCHAR")); + assert!(is_sql_type("BOOLEAN")); + assert!(is_sql_type("JSON")); + assert!(is_sql_type("JSONB")); + } + + #[test] + fn test_is_sql_type_case_insensitive() { + assert!(is_sql_type("integer")); + assert!(is_sql_type("varchar")); + } + + #[test] + fn test_is_not_sql_type() { + assert!(!is_sql_type("SELECT")); + assert!(!is_sql_type("users")); + } + + // --- List integrity --- + + #[test] + fn test_keywords_are_uppercase() { + for kw in SQL_KEYWORDS { + assert_eq!(*kw, kw.to_uppercase(), "Keyword not uppercase: {}", kw); + } + } + + #[test] + fn test_types_are_uppercase() { + for ty in SQL_TYPES { + assert_eq!(*ty, ty.to_uppercase(), "Type not uppercase: {}", ty); + } + } + + #[test] + fn test_no_duplicate_keywords() { + let mut seen = std::collections::HashSet::new(); + for kw in SQL_KEYWORDS { + assert!(seen.insert(*kw), "Duplicate keyword: {}", kw); + } + } + + #[test] + fn test_no_duplicate_types() { + let mut seen = std::collections::HashSet::new(); + for ty in SQL_TYPES { + assert!(seen.insert(*ty), "Duplicate type: {}", ty); + } + } +}