Added tests for 3 uncovered modules: - db: open, migrate, idempotent migration, FK/WAL pragmas, clone shares conn - theme: seed_builtins (7 themes), idempotent seed, get_by_name, hex color validation, sort ordering, case sensitivity - rdp/input: scancode lookup, extended key detection, value extraction, mouse flag composition, map coverage assertion Existing test count: 52 (vault, connections, credentials, settings, host_keys) New tests added: 30 Total: 82 tests, all passing, zero compiler warnings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
465 lines
16 KiB
Rust
465 lines
16 KiB
Rust
use rusqlite::params;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::db::Database;
|
|
|
|
// ── domain types ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Theme {
|
|
pub id: i64,
|
|
pub name: String,
|
|
pub foreground: String,
|
|
pub background: String,
|
|
pub cursor: String,
|
|
pub black: String,
|
|
pub red: String,
|
|
pub green: String,
|
|
pub yellow: String,
|
|
pub blue: String,
|
|
pub magenta: String,
|
|
pub cyan: String,
|
|
pub white: String,
|
|
pub bright_black: String,
|
|
pub bright_red: String,
|
|
pub bright_green: String,
|
|
pub bright_yellow: String,
|
|
pub bright_blue: String,
|
|
pub bright_magenta: String,
|
|
pub bright_cyan: String,
|
|
pub bright_white: String,
|
|
pub is_builtin: bool,
|
|
}
|
|
|
|
// ── helper struct for seed data ───────────────────────────────────────────────
|
|
|
|
struct BuiltinTheme {
|
|
name: &'static str,
|
|
foreground: &'static str,
|
|
background: &'static str,
|
|
cursor: &'static str,
|
|
black: &'static str,
|
|
red: &'static str,
|
|
green: &'static str,
|
|
yellow: &'static str,
|
|
blue: &'static str,
|
|
magenta: &'static str,
|
|
cyan: &'static str,
|
|
white: &'static str,
|
|
bright_black: &'static str,
|
|
bright_red: &'static str,
|
|
bright_green: &'static str,
|
|
bright_yellow: &'static str,
|
|
bright_blue: &'static str,
|
|
bright_magenta: &'static str,
|
|
bright_cyan: &'static str,
|
|
bright_white: &'static str,
|
|
}
|
|
|
|
// ── service ───────────────────────────────────────────────────────────────────
|
|
|
|
pub struct ThemeService {
|
|
db: Database,
|
|
}
|
|
|
|
impl ThemeService {
|
|
pub fn new(db: Database) -> Self {
|
|
Self { db }
|
|
}
|
|
|
|
/// Insert the 7 built-in themes if they don't exist yet.
|
|
///
|
|
/// Uses INSERT OR IGNORE so re-running on an existing database is safe.
|
|
pub fn seed_builtins(&self) {
|
|
let themes: &[BuiltinTheme] = &[
|
|
BuiltinTheme {
|
|
name: "Dracula",
|
|
foreground: "#f8f8f2",
|
|
background: "#282a36",
|
|
cursor: "#f8f8f2",
|
|
black: "#21222c",
|
|
red: "#ff5555",
|
|
green: "#50fa7b",
|
|
yellow: "#f1fa8c",
|
|
blue: "#bd93f9",
|
|
magenta: "#ff79c6",
|
|
cyan: "#8be9fd",
|
|
white: "#f8f8f2",
|
|
bright_black: "#6272a4",
|
|
bright_red: "#ff6e6e",
|
|
bright_green: "#69ff94",
|
|
bright_yellow: "#ffffa5",
|
|
bright_blue: "#d6acff",
|
|
bright_magenta: "#ff92df",
|
|
bright_cyan: "#a4ffff",
|
|
bright_white: "#ffffff",
|
|
},
|
|
BuiltinTheme {
|
|
name: "Nord",
|
|
foreground: "#d8dee9",
|
|
background: "#2e3440",
|
|
cursor: "#d8dee9",
|
|
black: "#3b4252",
|
|
red: "#bf616a",
|
|
green: "#a3be8c",
|
|
yellow: "#ebcb8b",
|
|
blue: "#81a1c1",
|
|
magenta: "#b48ead",
|
|
cyan: "#88c0d0",
|
|
white: "#e5e9f0",
|
|
bright_black: "#4c566a",
|
|
bright_red: "#bf616a",
|
|
bright_green: "#a3be8c",
|
|
bright_yellow: "#ebcb8b",
|
|
bright_blue: "#81a1c1",
|
|
bright_magenta: "#b48ead",
|
|
bright_cyan: "#8fbcbb",
|
|
bright_white: "#eceff4",
|
|
},
|
|
BuiltinTheme {
|
|
name: "Monokai",
|
|
foreground: "#f8f8f2",
|
|
background: "#272822",
|
|
cursor: "#f8f8f0",
|
|
black: "#272822",
|
|
red: "#f92672",
|
|
green: "#a6e22e",
|
|
yellow: "#f4bf75",
|
|
blue: "#66d9ef",
|
|
magenta: "#ae81ff",
|
|
cyan: "#a1efe4",
|
|
white: "#f8f8f2",
|
|
bright_black: "#75715e",
|
|
bright_red: "#f92672",
|
|
bright_green: "#a6e22e",
|
|
bright_yellow: "#f4bf75",
|
|
bright_blue: "#66d9ef",
|
|
bright_magenta: "#ae81ff",
|
|
bright_cyan: "#a1efe4",
|
|
bright_white: "#f9f8f5",
|
|
},
|
|
BuiltinTheme {
|
|
name: "One Dark",
|
|
foreground: "#abb2bf",
|
|
background: "#282c34",
|
|
cursor: "#528bff",
|
|
black: "#282c34",
|
|
red: "#e06c75",
|
|
green: "#98c379",
|
|
yellow: "#e5c07b",
|
|
blue: "#61afef",
|
|
magenta: "#c678dd",
|
|
cyan: "#56b6c2",
|
|
white: "#abb2bf",
|
|
bright_black: "#545862",
|
|
bright_red: "#e06c75",
|
|
bright_green: "#98c379",
|
|
bright_yellow: "#e5c07b",
|
|
bright_blue: "#61afef",
|
|
bright_magenta: "#c678dd",
|
|
bright_cyan: "#56b6c2",
|
|
bright_white: "#c8ccd4",
|
|
},
|
|
BuiltinTheme {
|
|
name: "Solarized Dark",
|
|
foreground: "#839496",
|
|
background: "#002b36",
|
|
cursor: "#839496",
|
|
black: "#073642",
|
|
red: "#dc322f",
|
|
green: "#859900",
|
|
yellow: "#b58900",
|
|
blue: "#268bd2",
|
|
magenta: "#d33682",
|
|
cyan: "#2aa198",
|
|
white: "#eee8d5",
|
|
bright_black: "#002b36",
|
|
bright_red: "#cb4b16",
|
|
bright_green: "#586e75",
|
|
bright_yellow: "#657b83",
|
|
bright_blue: "#839496",
|
|
bright_magenta: "#6c71c4",
|
|
bright_cyan: "#93a1a1",
|
|
bright_white: "#fdf6e3",
|
|
},
|
|
BuiltinTheme {
|
|
name: "Gruvbox Dark",
|
|
foreground: "#ebdbb2",
|
|
background: "#282828",
|
|
cursor: "#ebdbb2",
|
|
black: "#282828",
|
|
red: "#cc241d",
|
|
green: "#98971a",
|
|
yellow: "#d79921",
|
|
blue: "#458588",
|
|
magenta: "#b16286",
|
|
cyan: "#689d6a",
|
|
white: "#a89984",
|
|
bright_black: "#928374",
|
|
bright_red: "#fb4934",
|
|
bright_green: "#b8bb26",
|
|
bright_yellow: "#fabd2f",
|
|
bright_blue: "#83a598",
|
|
bright_magenta: "#d3869b",
|
|
bright_cyan: "#8ec07c",
|
|
bright_white: "#ebdbb2",
|
|
},
|
|
BuiltinTheme {
|
|
name: "MobaXTerm Classic",
|
|
foreground: "#ececec",
|
|
background: "#242424",
|
|
cursor: "#b4b4c0",
|
|
black: "#000000",
|
|
red: "#aa4244",
|
|
green: "#7e8d53",
|
|
yellow: "#e4b46d",
|
|
blue: "#6e9aba",
|
|
magenta: "#9e5085",
|
|
cyan: "#80d5cf",
|
|
white: "#cccccc",
|
|
bright_black: "#808080",
|
|
bright_red: "#cc7b7d",
|
|
bright_green: "#a5b17c",
|
|
bright_yellow: "#ecc995",
|
|
bright_blue: "#96b6cd",
|
|
bright_magenta: "#c083ac",
|
|
bright_cyan: "#a9e2de",
|
|
bright_white: "#cccccc",
|
|
},
|
|
];
|
|
|
|
let conn = self.db.conn();
|
|
for t in themes {
|
|
if let Err(e) = conn.execute(
|
|
"INSERT OR IGNORE INTO themes (
|
|
name, foreground, background, cursor,
|
|
black, red, green, yellow, blue, magenta, cyan, white,
|
|
bright_black, bright_red, bright_green, bright_yellow,
|
|
bright_blue, bright_magenta, bright_cyan, bright_white,
|
|
is_builtin
|
|
) VALUES (
|
|
?1, ?2, ?3, ?4,
|
|
?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
|
|
?13, ?14, ?15, ?16,
|
|
?17, ?18, ?19, ?20,
|
|
1
|
|
)",
|
|
params![
|
|
t.name, t.foreground, t.background, t.cursor,
|
|
t.black, t.red, t.green, t.yellow,
|
|
t.blue, t.magenta, t.cyan, t.white,
|
|
t.bright_black, t.bright_red, t.bright_green, t.bright_yellow,
|
|
t.bright_blue, t.bright_magenta, t.bright_cyan, t.bright_white,
|
|
],
|
|
) {
|
|
eprintln!("theme::seed_builtins: failed to seed '{}': {}", t.name, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Return all themes ordered by built-in first, then name.
|
|
pub fn list(&self) -> Vec<Theme> {
|
|
let conn = self.db.conn();
|
|
let mut stmt = match conn.prepare(
|
|
"SELECT id, name, foreground, background, cursor,
|
|
black, red, green, yellow, blue, magenta, cyan, white,
|
|
bright_black, bright_red, bright_green, bright_yellow,
|
|
bright_blue, bright_magenta, bright_cyan, bright_white,
|
|
is_builtin
|
|
FROM themes
|
|
ORDER BY is_builtin DESC, name COLLATE NOCASE ASC",
|
|
) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("theme::list: failed to prepare query: {}", e);
|
|
return vec![];
|
|
}
|
|
};
|
|
|
|
match stmt.query_map([], map_theme_row) {
|
|
Ok(rows) => rows
|
|
.filter_map(|r| {
|
|
r.map_err(|e| eprintln!("theme::list: row error: {}", e))
|
|
.ok()
|
|
})
|
|
.collect(),
|
|
Err(e) => {
|
|
eprintln!("theme::list: query failed: {}", e);
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Look up a theme by exact name (case-sensitive).
|
|
pub fn get_by_name(&self, name: &str) -> Option<Theme> {
|
|
let conn = self.db.conn();
|
|
conn.query_row(
|
|
"SELECT id, name, foreground, background, cursor,
|
|
black, red, green, yellow, blue, magenta, cyan, white,
|
|
bright_black, bright_red, bright_green, bright_yellow,
|
|
bright_blue, bright_magenta, bright_cyan, bright_white,
|
|
is_builtin
|
|
FROM themes
|
|
WHERE name = ?1",
|
|
params![name],
|
|
map_theme_row,
|
|
)
|
|
.ok()
|
|
}
|
|
}
|
|
|
|
// ── private helpers ───────────────────────────────────────────────────────────
|
|
|
|
fn map_theme_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Theme> {
|
|
Ok(Theme {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
foreground: row.get(2)?,
|
|
background: row.get(3)?,
|
|
cursor: row.get(4)?,
|
|
black: row.get(5)?,
|
|
red: row.get(6)?,
|
|
green: row.get(7)?,
|
|
yellow: row.get(8)?,
|
|
blue: row.get(9)?,
|
|
magenta: row.get(10)?,
|
|
cyan: row.get(11)?,
|
|
white: row.get(12)?,
|
|
bright_black: row.get(13)?,
|
|
bright_red: row.get(14)?,
|
|
bright_green: row.get(15)?,
|
|
bright_yellow: row.get(16)?,
|
|
bright_blue: row.get(17)?,
|
|
bright_magenta: row.get(18)?,
|
|
bright_cyan: row.get(19)?,
|
|
bright_white: row.get(20)?,
|
|
is_builtin: row.get(21)?,
|
|
})
|
|
}
|
|
|
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::Database;
|
|
|
|
fn make_service() -> ThemeService {
|
|
let db = Database::open(std::path::Path::new(":memory:")).unwrap();
|
|
db.migrate().unwrap();
|
|
ThemeService::new(db)
|
|
}
|
|
|
|
#[test]
|
|
fn list_empty_before_seed() {
|
|
let svc = make_service();
|
|
assert!(svc.list().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn seed_builtins_creates_seven_themes() {
|
|
let svc = make_service();
|
|
svc.seed_builtins();
|
|
let themes = svc.list();
|
|
assert_eq!(themes.len(), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn seed_builtins_is_idempotent() {
|
|
let svc = make_service();
|
|
svc.seed_builtins();
|
|
svc.seed_builtins(); // second run must not duplicate
|
|
assert_eq!(svc.list().len(), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn all_builtins_marked_as_builtin() {
|
|
let svc = make_service();
|
|
svc.seed_builtins();
|
|
for theme in svc.list() {
|
|
assert!(theme.is_builtin, "{} should be marked as builtin", theme.name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_names_correct() {
|
|
let svc = make_service();
|
|
svc.seed_builtins();
|
|
let names: Vec<String> = svc.list().into_iter().map(|t| t.name).collect();
|
|
assert!(names.contains(&"Dracula".to_string()));
|
|
assert!(names.contains(&"Nord".to_string()));
|
|
assert!(names.contains(&"Monokai".to_string()));
|
|
assert!(names.contains(&"One Dark".to_string()));
|
|
assert!(names.contains(&"Solarized Dark".to_string()));
|
|
assert!(names.contains(&"Gruvbox Dark".to_string()));
|
|
assert!(names.contains(&"MobaXTerm Classic".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn get_by_name_returns_theme() {
|
|
let svc = make_service();
|
|
svc.seed_builtins();
|
|
let theme = svc.get_by_name("Dracula");
|
|
assert!(theme.is_some());
|
|
let t = theme.unwrap();
|
|
assert_eq!(t.name, "Dracula");
|
|
assert_eq!(t.background, "#282a36");
|
|
assert_eq!(t.foreground, "#f8f8f2");
|
|
}
|
|
|
|
#[test]
|
|
fn get_by_name_missing_returns_none() {
|
|
let svc = make_service();
|
|
svc.seed_builtins();
|
|
assert!(svc.get_by_name("Nonexistent Theme").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn get_by_name_is_case_sensitive() {
|
|
let svc = make_service();
|
|
svc.seed_builtins();
|
|
assert!(svc.get_by_name("dracula").is_none()); // lowercase should not match
|
|
}
|
|
|
|
#[test]
|
|
fn all_themes_have_valid_hex_colors() {
|
|
let svc = make_service();
|
|
svc.seed_builtins();
|
|
for theme in svc.list() {
|
|
let colors = [
|
|
&theme.foreground, &theme.background, &theme.cursor,
|
|
&theme.black, &theme.red, &theme.green, &theme.yellow,
|
|
&theme.blue, &theme.magenta, &theme.cyan, &theme.white,
|
|
&theme.bright_black, &theme.bright_red, &theme.bright_green,
|
|
&theme.bright_yellow, &theme.bright_blue, &theme.bright_magenta,
|
|
&theme.bright_cyan, &theme.bright_white,
|
|
];
|
|
for color in colors {
|
|
assert!(
|
|
color.starts_with('#') && color.len() == 7,
|
|
"Theme '{}' has invalid color: '{}'",
|
|
theme.name,
|
|
color
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn list_ordered_builtin_first_then_name() {
|
|
let svc = make_service();
|
|
svc.seed_builtins();
|
|
let themes = svc.list();
|
|
// All are builtin, so should be ordered by name (case-insensitive)
|
|
for w in themes.windows(2) {
|
|
assert!(
|
|
w[0].name.to_lowercase() <= w[1].name.to_lowercase(),
|
|
"'{}' should come before '{}'",
|
|
w[0].name,
|
|
w[1].name
|
|
);
|
|
}
|
|
}
|
|
}
|