feat: add presentation formatting, filter options, and testping command
This commit is contained in:
parent
34d54b5c45
commit
6ffdb4ca4f
4 changed files with 1327 additions and 245 deletions
|
|
@ -7,7 +7,7 @@ use crate::Error;
|
|||
const ENDPOINT: &str = "https://openrouter.ai/api/v1/models";
|
||||
const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Model {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
|
|
@ -45,7 +45,7 @@ where
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Event {
|
||||
pub model: Model,
|
||||
pub kind: String,
|
||||
|
|
@ -96,7 +96,7 @@ impl Model {
|
|||
}
|
||||
}
|
||||
|
||||
fn validate(catalog: &BTreeMap<String, Model>) -> Result<(), Error> {
|
||||
pub(crate) fn validate(catalog: &BTreeMap<String, Model>) -> Result<(), Error> {
|
||||
if catalog.is_empty() {
|
||||
return Err("catalog is empty".into());
|
||||
}
|
||||
|
|
@ -353,4 +353,11 @@ mod tests {
|
|||
fn validate_rejects_empty_catalog() {
|
||||
assert!(validate(&BTreeMap::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_requires_keys_to_match_model_ids() {
|
||||
let model = model("example/model", &[("prompt", "0"), ("completion", "0")]);
|
||||
assert!(validate(&BTreeMap::from([("wrong/key".into(), model.clone())])).is_err());
|
||||
assert!(validate(&BTreeMap::from([(model.id.clone(), model)])).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1018
src/main.rs
1018
src/main.rs
File diff suppressed because it is too large
Load diff
330
src/presentation.rs
Normal file
330
src/presentation.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
use crate::{catalog::Model, serenity, store::State};
|
||||
|
||||
pub fn no_mentions() -> serenity::CreateAllowedMentions {
|
||||
serenity::CreateAllowedMentions::new()
|
||||
.all_users(false)
|
||||
.all_roles(false)
|
||||
.everyone(false)
|
||||
.replied_user(false)
|
||||
}
|
||||
|
||||
pub fn text(value: &str, limit: usize) -> String {
|
||||
if limit == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let mut result = String::new();
|
||||
let mut length = 0;
|
||||
for ch in value.chars().filter(|ch| !ch.is_control() || *ch == '\n') {
|
||||
let escape = matches!(
|
||||
ch,
|
||||
'\\' | '`' | '*' | '_' | '~' | '[' | ']' | '<' | '>' | '|' | '#' | '-'
|
||||
);
|
||||
let width = ch.len_utf16() + usize::from(escape);
|
||||
if length + width > limit.saturating_sub(1) {
|
||||
result.push('…');
|
||||
break;
|
||||
}
|
||||
if escape {
|
||||
result.push('\\');
|
||||
}
|
||||
result.push(ch);
|
||||
length += width;
|
||||
}
|
||||
if result.is_empty() {
|
||||
"—".into()
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub fn embed(title: &str) -> serenity::CreateEmbed {
|
||||
serenity::CreateEmbed::new()
|
||||
.title(text(title, 256))
|
||||
.color(0x8367ef)
|
||||
.footer(serenity::CreateEmbedFooter::new(
|
||||
"Model Radar • Catalog prices, not unlimited access • Limits may apply",
|
||||
))
|
||||
}
|
||||
|
||||
pub struct Browse {
|
||||
pub free_only: bool,
|
||||
pub search: String,
|
||||
pub min_context: u32,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub compact: bool,
|
||||
}
|
||||
|
||||
impl Default for Browse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
free_only: true,
|
||||
search: String::new(),
|
||||
min_context: 0,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
compact: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_line(model: &Model, compact: bool) -> String {
|
||||
let pricing = if model.is_free() {
|
||||
"Free"
|
||||
} else {
|
||||
"Paid / unknown"
|
||||
};
|
||||
if compact {
|
||||
format!(
|
||||
"{} · {} tokens · {pricing}",
|
||||
text(&model.id, 220),
|
||||
model.context_length
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"**{}**\n{} · {} tokens · {pricing}",
|
||||
text(&model.name, 85),
|
||||
text(&model.id, 220),
|
||||
model.context_length
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(state: &State, options: &Browse, poll_seconds: u64) -> serenity::CreateEmbed {
|
||||
let query = options.search.to_lowercase();
|
||||
let matches: Vec<_> = state
|
||||
.catalog
|
||||
.values()
|
||||
.filter(|model| {
|
||||
(!options.free_only || model.is_free())
|
||||
&& model.context_length >= u64::from(options.min_context)
|
||||
&& (model.id.to_lowercase().contains(&query)
|
||||
|| model.name.to_lowercase().contains(&query))
|
||||
})
|
||||
.collect();
|
||||
let size = options.page_size.clamp(1, 10) as usize;
|
||||
let pages = matches.len().div_ceil(size).max(1);
|
||||
let page = (options.page as usize).clamp(1, pages);
|
||||
let lines = matches
|
||||
.iter()
|
||||
.skip((page - 1) * size)
|
||||
.take(size)
|
||||
.map(|model| model_line(model, options.compact))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
let freshness = state
|
||||
.last_success
|
||||
.map(|time| format!("Last successful catalog fetch: <t:{time}:f> (<t:{time}:R>)."))
|
||||
.unwrap_or_else(|| "No successful catalog fetch recorded.".into());
|
||||
embed("Catalog snapshot")
|
||||
.description(format!("Snapshot only; not live updating. Catalog freshness, not bot uptime or health.\n{freshness}\n\n{}", if lines.is_empty() { "No matching models." } else { &lines }))
|
||||
.field("Catalog", format!("{} total · {} free · {} matching", state.catalog.len(), state.catalog.values().filter(|model| model.is_free()).count(), matches.len()), false)
|
||||
.field("Filters", format!("Scope: {}\nSearch: {}\nMinimum context: {} tokens\nCompact: {}", if options.free_only { "Free only" } else { "All models" }, text(&options.search, 240), options.min_context, options.compact), false)
|
||||
.field("Page", format!("{page}/{pages} · {size} per page"), true)
|
||||
.field("Poll interval", format!("{poll_seconds} seconds · run /status again to refresh this snapshot"), false)
|
||||
}
|
||||
|
||||
pub fn model_details(model: &Model, compact: bool, show_pricing: bool) -> serenity::CreateEmbed {
|
||||
let mut result = embed(&model.name)
|
||||
.field("Model ID", text(&model.id, 1024), false)
|
||||
.field("Context", format!("{} tokens", model.context_length), true)
|
||||
.field(
|
||||
"Free catalog pricing",
|
||||
if model.is_free() {
|
||||
"Yes"
|
||||
} else {
|
||||
"No / unknown"
|
||||
},
|
||||
true,
|
||||
);
|
||||
if !compact {
|
||||
result = result.description(text(&model.description, 2000));
|
||||
}
|
||||
if show_pricing {
|
||||
let prices = model
|
||||
.pricing
|
||||
.iter()
|
||||
.take(10)
|
||||
.map(|(key, value)| format!("{}: {}", text(key, 40), text(value, 45)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
result = result.field(
|
||||
"Raw USD prices (prompt/completion per token)",
|
||||
if prices.is_empty() {
|
||||
"Unavailable"
|
||||
} else {
|
||||
&prices
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn assert_embed_bounds(value: &serde_json::Value) {
|
||||
let units =
|
||||
|value: &serde_json::Value| value.as_str().unwrap_or_default().encode_utf16().count();
|
||||
assert!(units(&value["title"]) <= 256);
|
||||
assert!(units(&value["description"]) <= 4096);
|
||||
assert!(units(&value["footer"]["text"]) <= 2048);
|
||||
let mut total =
|
||||
units(&value["title"]) + units(&value["description"]) + units(&value["footer"]["text"]);
|
||||
if let Some(fields) = value["fields"].as_array() {
|
||||
assert!(fields.len() <= 25);
|
||||
for field in fields {
|
||||
assert!((1..=256).contains(&units(&field["name"])));
|
||||
assert!((1..=1024).contains(&units(&field["value"])));
|
||||
total += units(&field["name"]) + units(&field["value"]);
|
||||
}
|
||||
}
|
||||
assert!(total <= 6000, "embed has {total} UTF-16 units");
|
||||
}
|
||||
|
||||
fn model(id: &str, free: bool, context: u64) -> Model {
|
||||
Model {
|
||||
id: id.into(),
|
||||
name: "Example".into(),
|
||||
description: "Description".into(),
|
||||
context_length: context,
|
||||
pricing: BTreeMap::from([
|
||||
("prompt".into(), if free { "0" } else { "1" }.into()),
|
||||
("completion".into(), "0".into()),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_escaping_preserves_id_characters() {
|
||||
assert_eq!(
|
||||
text("vendor/model_under`tick", 1024),
|
||||
"vendor/model\\_under\\`tick"
|
||||
);
|
||||
assert_eq!(text("a\\b[*]", 1024), "a\\\\b\\[\\*\\]");
|
||||
let payload = serde_json::to_value(model_details(
|
||||
&model("vendor/model_under`tick", true, 8192),
|
||||
false,
|
||||
true,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(payload["fields"][0]["value"], "vendor/model\\_under\\`tick");
|
||||
assert_embed_bounds(&payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_lengths_and_all_payloads_are_bounded() {
|
||||
let large = "\u{1d11e}_`\\[]".repeat(2000);
|
||||
for limit in [0, 1, 2, 3, 40, 256, 1024, 2000] {
|
||||
assert!(text(&large, limit).encode_utf16().count() <= limit);
|
||||
}
|
||||
let mut example = model(&large, false, u64::MAX);
|
||||
example.name = large.clone();
|
||||
example.description = large.clone();
|
||||
example.pricing = (0..30)
|
||||
.map(|index| (format!("{index}{large}"), large.clone()))
|
||||
.collect();
|
||||
for compact in [false, true] {
|
||||
for show_pricing in [false, true] {
|
||||
let value =
|
||||
serde_json::to_value(model_details(&example, compact, show_pricing)).unwrap();
|
||||
assert_embed_bounds(&value);
|
||||
assert_eq!(
|
||||
value["fields"].as_array().unwrap().len(),
|
||||
if show_pricing { 4 } else { 3 }
|
||||
);
|
||||
assert_eq!(value.get("description").is_none(), compact);
|
||||
}
|
||||
let state = State {
|
||||
catalog: (0..30)
|
||||
.map(|index| (index.to_string(), example.clone()))
|
||||
.collect(),
|
||||
last_success: Some(u64::MAX),
|
||||
..Default::default()
|
||||
};
|
||||
for page_size in [0, 1, 10, u32::MAX] {
|
||||
let options = Browse {
|
||||
free_only: false,
|
||||
compact,
|
||||
page_size,
|
||||
..Default::default()
|
||||
};
|
||||
assert_embed_bounds(
|
||||
&serde_json::to_value(snapshot(&state, &options, 86400)).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_filters_pagination_and_freshness() {
|
||||
let state = State {
|
||||
catalog: [
|
||||
model("a/free", true, 100),
|
||||
model("b/paid", false, 200),
|
||||
model("c/free", true, 50),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|model| (model.id.clone(), model))
|
||||
.collect(),
|
||||
last_success: Some(123),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(snapshot(&state, &Browse::default(), 60)).unwrap();
|
||||
let description = value["description"].as_str().unwrap();
|
||||
assert!(description.contains("not live updating"));
|
||||
assert!(description.contains("not bot uptime or health"));
|
||||
assert!(description.contains("<t:123:f>"));
|
||||
assert!(!description.contains("b/paid"));
|
||||
assert!(description.contains("a/free") && description.contains("c/free"));
|
||||
let options = Browse {
|
||||
free_only: false,
|
||||
search: "PAID".into(),
|
||||
min_context: 200,
|
||||
page: u32::MAX,
|
||||
page_size: 1,
|
||||
compact: true,
|
||||
};
|
||||
let value = serde_json::to_value(snapshot(&state, &options, 75)).unwrap();
|
||||
assert!(value["description"].as_str().unwrap().contains("b/paid"));
|
||||
assert_eq!(value["fields"][2]["value"], "1/1 · 1 per page");
|
||||
assert!(
|
||||
value["fields"][3]["value"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("75 seconds")
|
||||
);
|
||||
let options = Browse {
|
||||
min_context: 201,
|
||||
..options
|
||||
};
|
||||
let value = serde_json::to_value(snapshot(&state, &options, 600)).unwrap();
|
||||
assert!(
|
||||
value["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("No matching models.")
|
||||
);
|
||||
let value =
|
||||
serde_json::to_value(snapshot(&State::default(), &Browse::default(), 600)).unwrap();
|
||||
assert!(
|
||||
value["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("No successful catalog fetch")
|
||||
);
|
||||
let options = Browse {
|
||||
free_only: false,
|
||||
page_size: 1,
|
||||
page: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(snapshot(&state, &options, 600)).unwrap();
|
||||
assert!(value["description"].as_str().unwrap().contains("b/paid"));
|
||||
assert!(!value["description"].as_str().unwrap().contains("a/free"));
|
||||
assert_eq!(value["fields"][2]["value"], "2/3 · 1 per page");
|
||||
}
|
||||
}
|
||||
211
src/store.rs
211
src/store.rs
|
|
@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Error;
|
||||
use crate::catalog::validate as validate_catalog;
|
||||
use crate::catalog::{Event, Model};
|
||||
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
|
@ -18,14 +19,57 @@ pub struct State {
|
|||
pub last_success: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Subscription {
|
||||
pub channel_id: u64,
|
||||
pub role_id: Option<u64>,
|
||||
pub free_only: bool,
|
||||
#[serde(default)]
|
||||
pub search: String,
|
||||
#[serde(default)]
|
||||
pub min_context: u32,
|
||||
#[serde(default = "default_true")]
|
||||
pub ping_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub compact: bool,
|
||||
pub pending: Vec<Event>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
pub fn matches(&self, model: &Model) -> bool {
|
||||
if (self.free_only && !model.is_free())
|
||||
|| model.context_length < u64::from(self.min_context)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let search = self.search.trim().to_lowercase();
|
||||
search.is_empty()
|
||||
|| model.name.to_lowercase().contains(&search)
|
||||
|| model.id.to_lowercase().contains(&search)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ids(state: &State) -> Result<(), Error> {
|
||||
for (guild, sub) in &state.subscriptions {
|
||||
if *guild == 0 || sub.channel_id == 0 {
|
||||
return Err("subscription IDs must be nonzero".into());
|
||||
}
|
||||
if let Some(role) = sub.role_id {
|
||||
if role == 0 {
|
||||
return Err("subscription role ID must be nonzero".into());
|
||||
}
|
||||
if role == *guild {
|
||||
return Err("subscription role ID must differ from guild ID".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Store {
|
||||
pub state: State,
|
||||
path: PathBuf,
|
||||
|
|
@ -33,11 +77,15 @@ pub struct Store {
|
|||
|
||||
impl Store {
|
||||
pub async fn load(path: PathBuf) -> Result<Self, Error> {
|
||||
let state = match tokio::fs::read(&path).await {
|
||||
let state: State = match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)?,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => State::default(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if state.initialized || !state.catalog.is_empty() {
|
||||
validate_catalog(&state.catalog)?;
|
||||
}
|
||||
validate_ids(&state)?;
|
||||
Ok(Self { state, path })
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +182,10 @@ mod tests {
|
|||
channel_id: 42,
|
||||
role_id: Some(99),
|
||||
free_only: true,
|
||||
search: " Example ".into(),
|
||||
min_context: 8192,
|
||||
ping_enabled: false,
|
||||
compact: true,
|
||||
pending: vec![Event {
|
||||
model,
|
||||
kind: "new".into(),
|
||||
|
|
@ -146,6 +198,10 @@ mod tests {
|
|||
channel_id: 43,
|
||||
role_id: None,
|
||||
free_only: false,
|
||||
search: String::new(),
|
||||
min_context: 0,
|
||||
ping_enabled: true,
|
||||
compact: false,
|
||||
pending: Vec::new(),
|
||||
},
|
||||
),
|
||||
|
|
@ -186,6 +242,157 @@ mod tests {
|
|||
assert_eq!(std::fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_states_preserve_pending_and_default_settings() {
|
||||
let directory = TestDirectory::new();
|
||||
let path = directory.state_path();
|
||||
let mut expected = populated_state();
|
||||
let mut legacy = serde_json::to_value(&expected).unwrap();
|
||||
for sub in legacy["subscriptions"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.values_mut()
|
||||
{
|
||||
for field in ["search", "min_context", "ping_enabled", "compact"] {
|
||||
sub.as_object_mut().unwrap().remove(field);
|
||||
}
|
||||
}
|
||||
for sub in expected.subscriptions.values_mut() {
|
||||
sub.search.clear();
|
||||
sub.min_context = 0;
|
||||
sub.ping_enabled = true;
|
||||
sub.compact = false;
|
||||
}
|
||||
let bytes = serde_json::to_vec(&legacy).unwrap();
|
||||
let decoded: State = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(decoded.subscriptions, expected.subscriptions);
|
||||
tokio::fs::write(&path, &bytes).await.unwrap();
|
||||
let mut loaded = Store::load(path.clone()).await.unwrap();
|
||||
assert_eq!(loaded.state.subscriptions, expected.subscriptions);
|
||||
assert_eq!(loaded.state.catalog, expected.catalog);
|
||||
assert_eq!(loaded.state.last_success, expected.last_success);
|
||||
assert!(loaded.state.initialized);
|
||||
assert_eq!(tokio::fs::read(&path).await.unwrap(), bytes);
|
||||
loaded.commit(loaded.state.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
Store::load(path).await.unwrap().state.subscriptions,
|
||||
expected.subscriptions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_filters_match_name_or_id_and_combine_constraints() {
|
||||
let state = populated_state();
|
||||
let mut model = state.catalog["example/model"].clone();
|
||||
model.name = "Friendly ÉXAMPLE".into();
|
||||
let mut sub = state.subscriptions[&u64::MAX].clone();
|
||||
for search in ["", " \t\n", " EXAMPLE/MO ", " fRiEnDlY ", "éxample"] {
|
||||
sub.search = search.into();
|
||||
assert!(sub.matches(&model), "{search:?}");
|
||||
}
|
||||
for search in ["missing", "Description", "friendly example/model"] {
|
||||
sub.search = search.into();
|
||||
assert!(!sub.matches(&model), "{search:?}");
|
||||
}
|
||||
sub.search = "friendly".into();
|
||||
sub.min_context = 8193;
|
||||
assert!(!sub.matches(&model));
|
||||
sub.min_context = 8192;
|
||||
assert!(sub.matches(&model));
|
||||
model.pricing.insert("prompt".into(), "1".into());
|
||||
assert!(!sub.matches(&model));
|
||||
sub.free_only = false;
|
||||
assert!(sub.matches(&model));
|
||||
sub.search = "missing".into();
|
||||
assert!(!sub.matches(&model));
|
||||
sub.search.clear();
|
||||
model.context_length = 0;
|
||||
assert!(!sub.matches(&model));
|
||||
sub.min_context = 0;
|
||||
assert!(sub.matches(&model));
|
||||
sub.min_context = u32::MAX;
|
||||
model.context_length = u64::MAX;
|
||||
assert!(sub.matches(&model));
|
||||
model.pricing.clear();
|
||||
sub.free_only = true;
|
||||
assert!(!sub.matches(&model));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_invalid_subscription_ids_without_overwriting() {
|
||||
let directory = TestDirectory::new();
|
||||
let path = directory.state_path();
|
||||
for (guild, channel, role) in [
|
||||
(0, 42, None),
|
||||
(1, 0, None),
|
||||
(1, 42, Some(0)),
|
||||
(1, 42, Some(1)),
|
||||
] {
|
||||
for ping_enabled in [false, true] {
|
||||
let mut state = populated_state();
|
||||
let mut sub = state.subscriptions[&u64::MAX].clone();
|
||||
sub.channel_id = channel;
|
||||
sub.role_id = role;
|
||||
sub.ping_enabled = ping_enabled;
|
||||
state.subscriptions = BTreeMap::from([(guild, sub)]);
|
||||
let bytes = serde_json::to_vec(&state).unwrap();
|
||||
tokio::fs::write(&path, &bytes).await.unwrap();
|
||||
assert!(Store::load(path.clone()).await.is_err());
|
||||
assert_eq!(tokio::fs::read(&path).await.unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_validates_catalog_for_initialized_and_uninitialized_states() {
|
||||
let directory = TestDirectory::new();
|
||||
let path = directory.state_path();
|
||||
for initialized in [false, true] {
|
||||
for invalid in ["empty", "key", "id", "name"] {
|
||||
let mut state = populated_state();
|
||||
state.initialized = initialized;
|
||||
match invalid {
|
||||
"empty" => state.catalog.clear(),
|
||||
"key" => {
|
||||
let model = state.catalog.pop_first().unwrap().1;
|
||||
state.catalog.insert("wrong/key".into(), model);
|
||||
}
|
||||
"id" => {
|
||||
let mut model = state.catalog.pop_first().unwrap().1;
|
||||
model.id = " ".into();
|
||||
state.catalog.insert(model.id.clone(), model);
|
||||
}
|
||||
_ => state.catalog.values_mut().next().unwrap().name = " ".into(),
|
||||
}
|
||||
let bytes = serde_json::to_vec(&state).unwrap();
|
||||
tokio::fs::write(&path, &bytes).await.unwrap();
|
||||
let loaded = Store::load(path.clone()).await;
|
||||
assert_eq!(loaded.is_ok(), !initialized && invalid == "empty");
|
||||
assert_eq!(tokio::fs::read(&path).await.unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_settings_round_trip_preserves_pending_without_filtering() {
|
||||
let directory = TestDirectory::new();
|
||||
let path = directory.state_path();
|
||||
let mut store = Store::load(path.clone()).await.unwrap();
|
||||
for ping_enabled in [false, true] {
|
||||
let mut state = populated_state();
|
||||
let sub = state.subscriptions.get_mut(&u64::MAX).unwrap();
|
||||
sub.search = " Unmatched ÉXAMPLE ".into();
|
||||
sub.min_context = u32::MAX;
|
||||
sub.ping_enabled = ping_enabled;
|
||||
sub.compact = !ping_enabled;
|
||||
assert!(!sub.matches(&sub.pending[0].model));
|
||||
store.commit(state.clone()).await.unwrap();
|
||||
let loaded = Store::load(path.clone()).await.unwrap();
|
||||
assert_eq!(loaded.state.subscriptions, state.subscriptions);
|
||||
assert_eq!(loaded.state.catalog, state.catalog);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_files_are_errors_and_not_overwritten() {
|
||||
let directory = TestDirectory::new();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue