v0.2.0
clean architecture refactor, performance, resilience, DX/UX architecture: - 13 crates with proper domain/application/infrastructure layers - domain crate: newtypes, ports (Plugin, AppLauncher), constants - kernel: pure orchestrator - shared UI state machine (k-launcher-ui-core) - merged plugin-api into domain as ports module - granular file structure (no monolithic lib.rs) - all tests extracted to tests/ directories features: - frecency boost in search results - empty query shows top frecent apps - append-only frecency log with configurable compaction - config-driven styling (all colors, sizes, debounce) - configurable terminal emulator, external plugin timeout - log rotation with max_log_files - loading indicator, descriptive placeholder text - graceful shutdown via iced::exit() + Plugin::shutdown() - --version flag, panic hook, signal handling (SIGINT/SIGTERM) - SpawnInTerminal in external plugin protocol performance: - ~1500 -> ~50 heap allocs per keystroke - reused Matcher, Pattern, char buffer across entries - Arc<str> for shared result fields - pre-filter before fuzzy matching - partial sort for top frecent IDs - cached lowercase names in entries resilience: - parking_lot (no mutex poisoning) - thiserror hierarchy (PluginError, ConfigError, AppError) - all silent error swallowing replaced with tracing::warn - config parse errors logged quality: - named constants (no magic strings/numbers) - named types (no anonymous tuples) - Rgba newtype with validation - domain newtype validation (debug_assert non-empty) - man page, LICENSE (MIT), PKGBUILD, example config - plugin development guide updated - make check (fmt + clippy + test), make dev (RUST_LOG=debug) style: format code for better readability in tests and function signatures fix: update build_entries function signature to ignore frecency parameter fix(review): bugs, arch violations, design smells P1 bugs: - unix_launcher: shell_split respects quoted args (was split_whitespace) - plugin-host: 5s timeout on external plugin search - ui: handle engine init panic, wire error state - ui-egui: read window config instead of always using defaults - plugin-url: use OpenPath action instead of SpawnProcess+xdg-open Architecture: - remove WindowConfig (mirror of WindowCfg); use WindowCfg directly - remove on_select closure from SearchResult (domain leakage) - remove LaunchAction::Custom; add Plugin::on_selected + SearchEngine::on_selected - apps: record frecency via on_selected instead of embedded closure Design smells: - frecency: extract decay_factor helper, write outside mutex - apps: remove cfg(test) cache_path hack; add new_for_test ctor - apps: stable ResultId using name+exec to prevent collision - files: stable ResultId using full path instead of index - plugin-host: remove k-launcher-os-bridge dep (WindowConfig gone) Update iced dependency in Cargo.toml to disable default features and add additional ones feat(app): enhance engine initialization with EngineHandle and update run function signature feat: production hardening (panic isolation, file logging, apps cache) - Kernel::search wraps each plugin in catch_unwind; panics are logged and return [] - init_logging() adds daily rolling file at ~/.local/share/k-launcher/logs/ - AppsPlugin caches entries to ~/.cache/k-launcher/apps.bin via bincode; stale-while-revalidate on subsequent launches - 57 tests pass refactor: remove client module and associated show command logic fix(app): format code for clarity in update function chore: update .gitignore and enhance README with compositor setup instructions chore(docs): remove unused screenshot file feature/prod-ready (#1) Reviewed-on: #1 fix(calc): remove ambiguous log alias, use ln/log2/log10 explicitly fix(calc): fix log/ln naming, cache math context, strengthen sin(pi) test feat(calc): add math functions (sqrt, sin, cos, etc.) and pi/e constants refactor(calc): rename preprocess, extend underscore test assertions feat(calc): strip underscore digit separators feat: update dependencies for improved compatibility and performance feat: add plugin-url for URL handling and open in browser functionality feat: add support for external plugins and enhance plugin management feat: add Makefile for build, run, and installation commands feat: add required features for k-launcher-egui and update dependencies feat: update README and add documentation for installation, configuration, usage, and plugin development feat: enhance configuration management and UI styling, remove unused theme module feat: add k-launcher-config crate for configuration management and integrate with existing components feat: add k-launcher-ui-egui crate for enhanced UI - Introduced a new crate `k-launcher-ui-egui` to provide a graphical user interface using eframe and egui. - Updated the workspace configuration in `Cargo.toml` to include the new crate. - Implemented the main application logic in `src/app.rs`, handling search functionality and user interactions. - Created a library entry point in `src/lib.rs` to expose the `run` function for launching the UI. - Modified the `k-launcher` crate to include a new binary target for the egui-based launcher. - Added a new main file `src/main_egui.rs` to initialize and run the egui UI with the existing kernel and launcher components. feat: implement OS bridge and enhance app launcher functionality feat: add FilesPlugin for file searching and integrate into KLauncher feat: implement frecency tracking for app usage and enhance search functionality feat: add CmdPlugin for executing terminal commands and update workspace configuration refactor: update dependencies and improve keyboard event handling in KLauncherApp refactor: simplify theme usage and enhance AppsPlugin structure feat: restructure k-launcher workspace and add core functionality - Updated Cargo.toml to include a new k-launcher crate and reorganized workspace members. - Introduced a README.md file detailing the project philosophy, architecture, and technical specifications. - Implemented a new Kernel struct in k-launcher-kernel for managing plugins and search functionality. - Created a Plugin trait for plugins to implement, allowing for asynchronous search operations. - Developed k-launcher-ui with an Iced-based UI for user interaction, including search input and result display. - Added AppsPlugin and CalcPlugin to handle application launching and basic calculations, respectively. - Established a theme module for UI styling, focusing on an Aero aesthetic. - Removed unnecessary main.rs files from plugin crates, streamlining the project structure. Initialize k-launcher project structure with multiple crates and basic configurations
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
[package]
|
||||
name = "k-launcher-kernel"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
futures = "0.3"
|
||||
k-launcher-domain = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
k-launcher-domain = { workspace = true }
|
||||
plugin-calc = { workspace = true }
|
||||
plugin-cmd = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
56
crates/k-launcher-kernel/src/kernel.rs
Normal file
56
crates/k-launcher-kernel/src/kernel.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future::join_all;
|
||||
|
||||
use k_launcher_domain::{Plugin, ResultId, SearchResult};
|
||||
|
||||
pub struct Kernel {
|
||||
plugins: Vec<Arc<dyn Plugin>>,
|
||||
max_results: usize,
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
|
||||
Self {
|
||||
plugins,
|
||||
max_results,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_selected(&self, id: &ResultId) {
|
||||
for plugin in &self.plugins {
|
||||
plugin.on_selected(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
for plugin in &self.plugins {
|
||||
plugin.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
use futures::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
|
||||
let futures = self
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
|
||||
let outcomes = join_all(futures).await;
|
||||
let mut flat: Vec<SearchResult> = outcomes
|
||||
.into_iter()
|
||||
.zip(self.plugins.iter())
|
||||
.flat_map(|(outcome, plugin)| match outcome {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
|
||||
vec![]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
flat.sort_by_key(|r| std::cmp::Reverse(r.score));
|
||||
flat.truncate(self.max_results);
|
||||
flat
|
||||
}
|
||||
}
|
||||
@@ -1,280 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
mod kernel;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::join_all;
|
||||
|
||||
// --- Newtypes ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ResultId(String);
|
||||
|
||||
impl ResultId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ResultTitle(String);
|
||||
|
||||
impl ResultTitle {
|
||||
pub fn new(title: impl Into<String>) -> Self {
|
||||
Self(title.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub struct Score(u32);
|
||||
|
||||
impl Score {
|
||||
pub fn new(value: u32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
pub fn value(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// --- LaunchAction (port) ---
|
||||
|
||||
pub enum LaunchAction {
|
||||
SpawnProcess(String),
|
||||
SpawnInTerminal(String),
|
||||
OpenPath(String),
|
||||
CopyToClipboard(String),
|
||||
}
|
||||
|
||||
// --- AppLauncher port trait ---
|
||||
|
||||
pub trait AppLauncher: Send + Sync {
|
||||
fn execute(&self, action: &LaunchAction);
|
||||
}
|
||||
|
||||
// --- SearchResult ---
|
||||
|
||||
pub struct SearchResult {
|
||||
pub id: ResultId,
|
||||
pub title: ResultTitle,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub score: Score,
|
||||
pub action: LaunchAction,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SearchResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SearchResult")
|
||||
.field("id", &self.id)
|
||||
.field("title", &self.title)
|
||||
.field("icon", &self.icon)
|
||||
.field("score", &self.score)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Plugin trait ---
|
||||
|
||||
#[async_trait]
|
||||
pub trait Plugin: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
fn on_selected(&self, _id: &ResultId) {}
|
||||
}
|
||||
|
||||
// --- SearchEngine port trait ---
|
||||
|
||||
#[async_trait]
|
||||
pub trait SearchEngine: Send + Sync {
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
fn on_selected(&self, id: &ResultId);
|
||||
}
|
||||
|
||||
// --- NullSearchEngine ---
|
||||
|
||||
pub struct NullSearchEngine;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchEngine for NullSearchEngine {
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
vec![]
|
||||
}
|
||||
fn on_selected(&self, _id: &ResultId) {}
|
||||
}
|
||||
|
||||
// --- Kernel (Application use case) ---
|
||||
|
||||
pub struct Kernel {
|
||||
plugins: Vec<Arc<dyn Plugin>>,
|
||||
max_results: usize,
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
|
||||
Self {
|
||||
plugins,
|
||||
max_results,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_selected(&self, id: &ResultId) {
|
||||
for plugin in &self.plugins {
|
||||
plugin.on_selected(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
use futures::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
|
||||
let futures = self
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
|
||||
let outcomes = join_all(futures).await;
|
||||
let mut flat: Vec<SearchResult> = outcomes
|
||||
.into_iter()
|
||||
.zip(self.plugins.iter())
|
||||
.flat_map(|(outcome, plugin)| match outcome {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
|
||||
vec![]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
flat.sort_by(|a, b| b.score.cmp(&a.score));
|
||||
flat.truncate(self.max_results);
|
||||
flat
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchEngine for Kernel {
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
self.search(query).await
|
||||
}
|
||||
fn on_selected(&self, id: &ResultId) {
|
||||
self.on_selected(id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct MockPlugin {
|
||||
results: Vec<(&'static str, u32)>,
|
||||
}
|
||||
|
||||
impl MockPlugin {
|
||||
fn returns(results: Vec<(&'static str, u32)>) -> Self {
|
||||
Self { results }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for MockPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
self.results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (title, score))| SearchResult {
|
||||
id: ResultId::new(format!("id-{i}")),
|
||||
title: ResultTitle::new(*title),
|
||||
description: None,
|
||||
icon: None,
|
||||
score: Score::new(*score),
|
||||
action: LaunchAction::SpawnProcess("mock".to_string()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_result_id() {
|
||||
assert_eq!(ResultId::new("x").as_str(), "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_score() {
|
||||
assert_eq!(Score::new(42).value(), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_title() {
|
||||
assert_eq!(ResultTitle::new("hello").as_str(), "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_kernel_returns_empty() {
|
||||
let k = Kernel::new(vec![], 8);
|
||||
assert!(k.search("x").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_sorts_by_score_desc() {
|
||||
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||
("lower", 5),
|
||||
("higher", 10),
|
||||
("middle", 7),
|
||||
]));
|
||||
let k = Kernel::new(vec![plugin], 8);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results[0].score.value(), 10);
|
||||
assert_eq!(results[1].score.value(), 7);
|
||||
assert_eq!(results[2].score.value(), 5);
|
||||
}
|
||||
|
||||
struct PanicPlugin;
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for PanicPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"panic-plugin"
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
panic!("test panic");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_continues_after_plugin_panic() {
|
||||
let panic_plugin = Arc::new(PanicPlugin);
|
||||
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
|
||||
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title.as_str(), "survivor");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_truncates_at_max_results() {
|
||||
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||
("a", 10),
|
||||
("b", 9),
|
||||
("c", 8),
|
||||
("d", 7),
|
||||
("e", 6),
|
||||
]));
|
||||
let k = Kernel::new(vec![plugin], 3);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0].score.value(), 10);
|
||||
assert_eq!(results[2].score.value(), 8);
|
||||
}
|
||||
}
|
||||
pub use kernel::*;
|
||||
|
||||
42
crates/k-launcher-kernel/tests/integration.rs
Normal file
42
crates/k-launcher-kernel/tests/integration.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_kernel::Kernel;
|
||||
use plugin_calc::CalcPlugin;
|
||||
use plugin_cmd::CmdPlugin;
|
||||
|
||||
fn make_kernel() -> Kernel {
|
||||
Kernel::new(
|
||||
vec![Arc::new(CalcPlugin::new()), Arc::new(CmdPlugin::new())],
|
||||
8,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_pipeline_calc() {
|
||||
let kernel = make_kernel();
|
||||
let results = kernel.search("2+2").await;
|
||||
assert!(!results.is_empty());
|
||||
assert_eq!(results[0].title.as_str(), "= 4");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_pipeline_cmd() {
|
||||
let kernel = make_kernel();
|
||||
let results = kernel.search("> echo hello").await;
|
||||
assert!(!results.is_empty());
|
||||
assert_eq!(results[0].title.as_str(), "Run: echo hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_pipeline_no_match() {
|
||||
let kernel = make_kernel();
|
||||
let results = kernel.search("xyzzy").await;
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_pipeline_empty_query() {
|
||||
let kernel = make_kernel();
|
||||
let results = kernel.search("").await;
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
107
crates/k-launcher-kernel/tests/kernel.rs
Normal file
107
crates/k-launcher-kernel/tests/kernel.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use k_launcher_domain::Plugin;
|
||||
use k_launcher_domain::{LaunchAction, ResultId, ResultTitle, Score, SearchResult};
|
||||
use k_launcher_kernel::Kernel;
|
||||
|
||||
struct MockResult {
|
||||
title: &'static str,
|
||||
score: u32,
|
||||
}
|
||||
|
||||
struct MockPlugin {
|
||||
results: Vec<MockResult>,
|
||||
}
|
||||
|
||||
impl MockPlugin {
|
||||
fn returns(results: Vec<(&'static str, u32)>) -> Self {
|
||||
Self {
|
||||
results: results
|
||||
.into_iter()
|
||||
.map(|(title, score)| MockResult { title, score })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for MockPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
self.results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| SearchResult {
|
||||
id: ResultId::new(format!("id-{i}")),
|
||||
title: ResultTitle::new(r.title),
|
||||
description: None,
|
||||
icon: None,
|
||||
score: Score::new(r.score),
|
||||
action: LaunchAction::SpawnProcess("mock".to_string()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_kernel_returns_empty() {
|
||||
let k = Kernel::new(vec![], 8);
|
||||
assert!(k.search("x").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_sorts_by_score_desc() {
|
||||
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||
("lower", 5),
|
||||
("higher", 10),
|
||||
("middle", 7),
|
||||
]));
|
||||
let k = Kernel::new(vec![plugin], 8);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results[0].score.value(), 10);
|
||||
assert_eq!(results[1].score.value(), 7);
|
||||
assert_eq!(results[2].score.value(), 5);
|
||||
}
|
||||
|
||||
struct PanicPlugin;
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for PanicPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"panic-plugin"
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
panic!("test panic");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_continues_after_plugin_panic() {
|
||||
let panic_plugin = Arc::new(PanicPlugin);
|
||||
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
|
||||
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title.as_str(), "survivor");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_truncates_at_max_results() {
|
||||
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||
("a", 10),
|
||||
("b", 9),
|
||||
("c", 8),
|
||||
("d", 7),
|
||||
("e", 6),
|
||||
]));
|
||||
let k = Kernel::new(vec![plugin], 3);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0].score.value(), 10);
|
||||
assert_eq!(results[2].score.value(), 8);
|
||||
}
|
||||
Reference in New Issue
Block a user