diff --git a/Cargo.lock b/Cargo.lock index 1a96b62ec0a40c61587e5eec105ac6929db5237b..c64da9669187120d299c9ad8265e601a924a0fac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1996,6 +1996,7 @@ name = "slug-types" version = "0.1.0" dependencies = [ "serde", + "serde_json", "url", ] diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs index 2ab25c778ab1ccb02ba5ac2ebef3b7a059203249..8cad05bd12d8fe09ad354137dea03efd11466694 100644 --- a/server/src/api/helpers.rs +++ b/server/src/api/helpers.rs @@ -144,6 +144,23 @@ pub fn paginate_rankings( (out_components, out_unranked) } +/// Flatten a paginated global rank page into the `items` list CLI ≤0.0.68 requires. +pub fn flatten_global_rank_items( + components: &[RankComponent], + unranked_items: &[GardenItemUrl], + want_percent: bool, +) -> Vec { + components + .iter() + .flat_map(|component| component.ranking.iter().cloned()) + .chain(unranked_items.iter().map(|item| RankRow { + item: item.clone(), + score: 0.0, + percent: want_percent.then_some(0.0), + })) + .collect() +} + pub fn pick_random_distinct_item_pair(items: &[ItemId]) -> Option<(ItemId, ItemId)> { use rand::seq::SliceRandom; if items.len() < 2 { @@ -251,3 +268,28 @@ pub fn vote_touches_path(content: &ContentState, a: &str, b: &str, parent_canon: let members = content.members_of(&parent); members.iter().any(|m| *m == a || *m == b) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flatten_global_rank_items_ranked_then_unranked() { + let components = vec![RankComponent { + pairs: 1, + ranking: vec![RankRow { + item: GardenItemUrl("https://slug.social/~/rust".into()), + score: 1.5, + percent: Some(100.0), + }], + }]; + let unranked = vec![GardenItemUrl("https://slug.social/~/go".into())]; + let items = flatten_global_rank_items(&components, &unranked, true); + assert_eq!(items.len(), 2); + assert_eq!(items[0].item.as_str(), "https://slug.social/~/rust"); + assert_eq!(items[0].score, 1.5); + assert_eq!(items[1].item.as_str(), "https://slug.social/~/go"); + assert_eq!(items[1].score, 0.0); + assert_eq!(items[1].percent, Some(0.0)); + } +} diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 29749c3c757f79f99b6e9acf181b451e844d6e74..8c70f741f1ea5943fa03df26624021dea1b8a49a 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -22,8 +22,9 @@ use crate::{ use super::auth::{parse_bearer, verify_bearer_principal}; use super::helpers::{ - compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs, - pick_random_distinct_item_pair, validate_garden_parent_scope_paths, vote_touches_path, + compute_connectivity_stats, flatten_global_rank_items, is_pair_voted, now_ms, + paginate_rankings, parse_parent_specs, pick_random_distinct_item_pair, + validate_garden_parent_scope_paths, vote_touches_path, }; use super::validate::validate_ingest_document; @@ -1438,6 +1439,7 @@ pub async fn dispatch_rpc(state: &AppState, headers: &HeaderMap, cmd: RpcCommand .collect(); let (components, unranked_items) = paginate_rankings(components, unranked, offset, Some(limit)); + let items = flatten_global_rank_items(&components, &unranked_items, want_percent); line_ok(RpcResult::GlobalRank(GlobalRankResponse { ranked_total, @@ -1446,6 +1448,7 @@ pub async fn dispatch_rpc(state: &AppState, headers: &HeaderMap, cmd: RpcCommand limit, components, unranked_items, + items, })) } } diff --git a/server/tests/integration_rpc.rs b/server/tests/integration_rpc.rs index 11429a56b9620ba1d56a4c2dff439000d2e67961..935eeee24a639d987cbaf5e5929471c4a0077579 100644 --- a/server/tests/integration_rpc.rs +++ b/server/tests/integration_rpc.rs @@ -253,6 +253,23 @@ async fn test_rank_endpoint() { assert_eq!(ranking.len(), 2); assert_eq!(ranking[0]["item"], "https://slug.social/~/rust"); assert!(rank["unranked_items"].is_array()); + + let global_batch = serde_json::json!([{ + "GetGlobalRank": { + "room": "public", + "limit": 25, + "offset": 0, + "percent": false + } + }]); + let global_body = rpc_batch(&client, addr, None, global_batch).await; + let global = &global_body["results"][0]["result"]["GlobalRank"]; + assert!( + global["items"].is_array(), + "CLI ≤0.0.68 requires flattened items: {global}" + ); + assert!(!global["items"].as_array().unwrap().is_empty()); + assert!(global["components"].is_array()); } #[tokio::test] diff --git a/types/Cargo.toml b/types/Cargo.toml index 5dc3becf238f23243313dff0484c682a7b225737..efdcdfcdc182e501ce079003eb4f0aec474589a8 100644 --- a/types/Cargo.toml +++ b/types/Cargo.toml @@ -6,3 +6,6 @@ edition = "2021" [dependencies] serde = { version = "1.0", features = ["derive"] } url = { version = "2.5", features = ["serde"] } + +[dev-dependencies] +serde_json = "1" diff --git a/types/src/lib.rs b/types/src/lib.rs index 9a6da984fde1c7c32b57e1df6f4ce0a83868d942..40ac995ab97fe17b795a11f64b000196fa95f075 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -55,9 +55,15 @@ pub struct GlobalRankResponse { pub limit: usize, /// Ranked components, ordered largest-first. Scores and percentages are comparable within, /// but not across, components. + #[serde(default)] pub components: Vec, /// Unranked items included in this page after the ranked components. + #[serde(default)] pub unranked_items: Vec, + /// Flattened page for CLI ≤0.0.68: ranked rows first, then unranked at score 0. + /// Always serialized so published clients that require `items` keep working. + #[serde(default)] + pub items: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -772,3 +778,64 @@ pub struct VoteResponse { pub ranking: Vec, pub next: NextMoves, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn global_rank_response_deserializes_legacy_items_and_components() { + let legacy = r#"{ + "ranked_total": 1, + "unranked_total": 0, + "offset": 0, + "limit": 25, + "items": [{"item": "https://slug.social/~/rust", "score": 1.0}] + }"#; + let parsed: GlobalRankResponse = serde_json::from_str(legacy).unwrap(); + assert_eq!(parsed.items.len(), 1); + assert!(parsed.components.is_empty()); + assert!(parsed.unranked_items.is_empty()); + + let modern = r#"{ + "ranked_total": 1, + "unranked_total": 1, + "offset": 0, + "limit": 25, + "components": [{"pairs": 1, "ranking": [{"item": "https://slug.social/~/rust", "score": 1.0}]}], + "unranked_items": ["https://slug.social/~/go"] + }"#; + let parsed: GlobalRankResponse = serde_json::from_str(modern).unwrap(); + assert_eq!(parsed.components.len(), 1); + assert_eq!(parsed.unranked_items.len(), 1); + assert!(parsed.items.is_empty()); + } + + #[test] + fn global_rank_response_serializes_items_for_old_cli() { + let resp = GlobalRankResponse { + ranked_total: 1, + unranked_total: 0, + offset: 0, + limit: 25, + components: vec![RankComponent { + pairs: 1, + ranking: vec![RankRow { + item: GardenItemUrl("https://slug.social/~/rust".into()), + score: 1.0, + percent: None, + }], + }], + unranked_items: vec![], + items: vec![RankRow { + item: GardenItemUrl("https://slug.social/~/rust".into()), + score: 1.0, + percent: None, + }], + }; + let json = serde_json::to_value(&resp).unwrap(); + assert!(json.get("items").and_then(|v| v.as_array()).is_some()); + assert_eq!(json["items"].as_array().unwrap().len(), 1); + assert_eq!(json["components"].as_array().unwrap().len(), 1); + } +}