Skip to content

Commit 40acae6

Browse files
committed
Add unit/integration tests and a CI workflow
Expands test coverage beyond the calculator: - applications: AppSource detection, .desktop parsing (including NoDisplay / missing-Exec skips), bulk loading from an extra dir, and query matching (by name, by id, no-match, empty-query truncation) - config: defaults, TOML round-trip, and serde fallback for partial/empty configs - providers: Item builder and Item/ProviderInfo -> protobuf conversions - manager: provider registration, score-based combining/sorting, max_results truncation, explicit-provider filtering, and prefix routing (via a mock provider) - server: end-to-end query and list-providers round-trips over a real Unix socket Adds .github/workflows/ci.yml running fmt check, build and test on pushes to main and on pull requests (installs protoc and uses --locked). https://claude.ai/code/session_01B33sAdjSZQQ6XRtFkxYTAb
1 parent 050ddca commit 40acae6

6 files changed

Lines changed: 589 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
env:
9+
CARGO_TERM_COLOR: always
10+
11+
jobs:
12+
test:
13+
name: Build & Test
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: Install protobuf compiler
19+
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
20+
21+
- name: Install Rust toolchain
22+
uses: dtolnay/rust-toolchain@stable
23+
with:
24+
components: rustfmt
25+
26+
- name: Cache cargo build
27+
uses: Swatinem/rust-cache@v2
28+
29+
- name: Check formatting
30+
run: cargo fmt --all -- --check
31+
32+
- name: Build
33+
run: cargo build --locked --verbose
34+
35+
- name: Test
36+
run: cargo test --locked --verbose

src/config.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,50 @@ impl Config {
151151
config_dir.join("datacube").join("config.toml")
152152
}
153153
}
154+
155+
#[cfg(test)]
156+
mod tests {
157+
use super::*;
158+
159+
#[test]
160+
fn defaults_are_sane() {
161+
let config = Config::default();
162+
assert_eq!(config.max_results, 50);
163+
assert!(config.providers.applications.enabled);
164+
assert!(config.providers.calculator.enabled);
165+
assert_eq!(config.providers.calculator.prefix, "=");
166+
assert!(config
167+
.socket_path
168+
.to_string_lossy()
169+
.ends_with("datacube.sock"));
170+
}
171+
172+
#[test]
173+
fn toml_round_trip() {
174+
let config = Config::default();
175+
let serialized = toml::to_string(&config).expect("serialize");
176+
let parsed: Config = toml::from_str(&serialized).expect("deserialize");
177+
assert_eq!(parsed.max_results, config.max_results);
178+
assert_eq!(
179+
parsed.providers.calculator.prefix,
180+
config.providers.calculator.prefix
181+
);
182+
assert_eq!(parsed.socket_path, config.socket_path);
183+
}
184+
185+
#[test]
186+
fn partial_config_uses_defaults() {
187+
// Only override max_results; everything else should fall back to defaults.
188+
let parsed: Config = toml::from_str("max_results = 7").expect("deserialize");
189+
assert_eq!(parsed.max_results, 7);
190+
assert!(parsed.providers.applications.enabled);
191+
assert_eq!(parsed.providers.calculator.prefix, "=");
192+
}
193+
194+
#[test]
195+
fn empty_config_is_all_defaults() {
196+
let parsed: Config = toml::from_str("").expect("deserialize");
197+
assert_eq!(parsed.max_results, 50);
198+
assert!(parsed.providers.applications.enabled);
199+
}
200+
}

src/providers/applications.rs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,3 +1105,208 @@ impl Provider for ApplicationsProvider {
11051105
Box::pin(async move { result })
11061106
}
11071107
}
1108+
1109+
#[cfg(test)]
1110+
mod tests {
1111+
use super::*;
1112+
use std::fs;
1113+
1114+
/// A self-cleaning temporary directory (avoids pulling in a dev-dependency).
1115+
struct TempDir {
1116+
path: PathBuf,
1117+
}
1118+
1119+
impl TempDir {
1120+
fn new() -> Self {
1121+
let path = std::env::temp_dir().join(format!("datacube-test-{}", uuid::Uuid::new_v4()));
1122+
fs::create_dir_all(&path).unwrap();
1123+
Self { path }
1124+
}
1125+
1126+
fn write(&self, name: &str, contents: &str) -> PathBuf {
1127+
let p = self.path.join(name);
1128+
fs::write(&p, contents).unwrap();
1129+
p
1130+
}
1131+
}
1132+
1133+
impl Drop for TempDir {
1134+
fn drop(&mut self) {
1135+
let _ = fs::remove_dir_all(&self.path);
1136+
}
1137+
}
1138+
1139+
fn make_entry(id: &str, name: &str) -> AppEntry {
1140+
AppEntry {
1141+
id: id.to_string(),
1142+
path: PathBuf::from(format!("/usr/share/applications/{id}.desktop")),
1143+
name: name.to_string(),
1144+
generic_name: None,
1145+
comment: None,
1146+
icon: "app-icon".to_string(),
1147+
icon_path: None,
1148+
keywords: Vec::new(),
1149+
terminal: false,
1150+
launch_count: 0,
1151+
source: AppSource::Native,
1152+
}
1153+
}
1154+
1155+
/// Build a provider directly from a set of entries, bypassing the
1156+
/// filesystem scan and background loader.
1157+
fn provider_with(entries: Vec<AppEntry>) -> ApplicationsProvider {
1158+
let map: HashMap<String, AppEntry> =
1159+
entries.into_iter().map(|e| (e.id.clone(), e)).collect();
1160+
ApplicationsProvider {
1161+
apps: Arc::new(RwLock::new(map)),
1162+
path_to_id: Arc::new(RwLock::new(HashMap::new())),
1163+
matcher: SkimMatcherV2::default(),
1164+
extra_dirs: Vec::new(),
1165+
watcher: None,
1166+
}
1167+
}
1168+
1169+
#[test]
1170+
fn app_source_from_path() {
1171+
assert_eq!(
1172+
AppSource::from_path(Path::new(
1173+
"/var/lib/flatpak/exports/share/applications/org.x.desktop"
1174+
)),
1175+
AppSource::Flatpak
1176+
);
1177+
assert_eq!(
1178+
AppSource::from_path(Path::new("/var/lib/snapd/desktop/applications/foo.desktop")),
1179+
AppSource::Snap
1180+
);
1181+
assert_eq!(
1182+
AppSource::from_path(Path::new("/usr/share/applications/foo.desktop")),
1183+
AppSource::Native
1184+
);
1185+
}
1186+
1187+
#[test]
1188+
fn app_source_as_str() {
1189+
assert_eq!(AppSource::Native.as_str(), "native");
1190+
assert_eq!(AppSource::Flatpak.as_str(), "flatpak");
1191+
assert_eq!(AppSource::Snap.as_str(), "snap");
1192+
}
1193+
1194+
#[test]
1195+
fn detects_desktop_files() {
1196+
assert!(ApplicationsProvider::is_desktop_file(Path::new(
1197+
"/a/b/foo.desktop"
1198+
)));
1199+
assert!(!ApplicationsProvider::is_desktop_file(Path::new(
1200+
"/a/b/foo.txt"
1201+
)));
1202+
assert!(!ApplicationsProvider::is_desktop_file(Path::new(
1203+
"/a/b/foo"
1204+
)));
1205+
}
1206+
1207+
#[test]
1208+
fn parse_desktop_file_basic() {
1209+
let dir = TempDir::new();
1210+
let path = dir.write(
1211+
"firefox.desktop",
1212+
"[Desktop Entry]\n\
1213+
Type=Application\n\
1214+
Name=Firefox\n\
1215+
GenericName=Web Browser\n\
1216+
Comment=Browse the web\n\
1217+
Exec=/usr/bin/firefox\n\
1218+
Icon=firefox\n\
1219+
Keywords=internet;browser;\n\
1220+
Terminal=false\n",
1221+
);
1222+
1223+
let entry = ApplicationsProvider::parse_desktop_file(&path).expect("should parse");
1224+
assert_eq!(entry.id, "firefox");
1225+
assert_eq!(entry.name, "Firefox");
1226+
assert_eq!(entry.generic_name.as_deref(), Some("Web Browser"));
1227+
assert_eq!(entry.comment.as_deref(), Some("Browse the web"));
1228+
assert_eq!(entry.icon, "firefox");
1229+
assert!(entry.keywords.iter().any(|k| k == "browser"));
1230+
assert!(!entry.terminal);
1231+
// Icon resolution is deferred - parse leaves it unset.
1232+
assert!(entry.icon_path.is_none());
1233+
}
1234+
1235+
#[test]
1236+
fn parse_desktop_file_skips_nodisplay_and_no_exec() {
1237+
let dir = TempDir::new();
1238+
1239+
let hidden = dir.write(
1240+
"hidden.desktop",
1241+
"[Desktop Entry]\nType=Application\nName=Hidden\nExec=/bin/true\nNoDisplay=true\n",
1242+
);
1243+
assert!(ApplicationsProvider::parse_desktop_file(&hidden).is_none());
1244+
1245+
let no_exec = dir.write(
1246+
"noexec.desktop",
1247+
"[Desktop Entry]\nType=Application\nName=NoExec\n",
1248+
);
1249+
assert!(ApplicationsProvider::parse_desktop_file(&no_exec).is_none());
1250+
}
1251+
1252+
#[test]
1253+
fn load_applications_into_reads_extra_dir() {
1254+
let dir = TempDir::new();
1255+
let unique = "datacube-unit-test-app-xyz";
1256+
dir.write(
1257+
&format!("{unique}.desktop"),
1258+
"[Desktop Entry]\nType=Application\nName=Datacube Unit Test App\nExec=/bin/true\nIcon=x\n",
1259+
);
1260+
1261+
let apps = Arc::new(RwLock::new(HashMap::new()));
1262+
let path_to_id = Arc::new(RwLock::new(HashMap::new()));
1263+
ApplicationsProvider::load_applications_into(&apps, &path_to_id, &[dir.path.clone()]);
1264+
1265+
let guard = apps.read().unwrap();
1266+
let entry = guard.get(unique).expect("temp app should be loaded");
1267+
assert_eq!(entry.name, "Datacube Unit Test App");
1268+
}
1269+
1270+
#[test]
1271+
fn query_matches_by_name() {
1272+
let provider = provider_with(vec![
1273+
make_entry("firefox", "Firefox"),
1274+
make_entry("gimp", "GIMP"),
1275+
make_entry("code", "Visual Studio Code"),
1276+
]);
1277+
1278+
let results = provider.query_impl("firefox", 10);
1279+
assert!(!results.is_empty());
1280+
assert_eq!(results[0].text, "Firefox");
1281+
assert_eq!(results[0].provider, "applications");
1282+
}
1283+
1284+
#[test]
1285+
fn query_matches_by_id() {
1286+
let mut entry = make_entry("org.mozilla.firefox", "Firefox");
1287+
entry.source = AppSource::Flatpak;
1288+
let provider = provider_with(vec![entry, make_entry("gimp", "GIMP")]);
1289+
1290+
let results = provider.query_impl("mozilla", 10);
1291+
assert_eq!(results.len(), 1);
1292+
assert_eq!(results[0].text, "Firefox");
1293+
}
1294+
1295+
#[test]
1296+
fn query_no_match_is_empty() {
1297+
let provider = provider_with(vec![make_entry("firefox", "Firefox")]);
1298+
assert!(provider.query_impl("zzzzzznotanapp", 10).is_empty());
1299+
}
1300+
1301+
#[test]
1302+
fn query_empty_returns_all_up_to_max() {
1303+
let provider = provider_with(vec![
1304+
make_entry("a", "Alpha"),
1305+
make_entry("b", "Beta"),
1306+
make_entry("c", "Gamma"),
1307+
]);
1308+
1309+
assert_eq!(provider.query_impl("", 10).len(), 3);
1310+
assert_eq!(provider.query_impl("", 2).len(), 2);
1311+
}
1312+
}

0 commit comments

Comments
 (0)