Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 170 additions & 1 deletion lib/api_projects/tests/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! Integration tests for project CRUD endpoints.

use bencher_api_tests::TestServer;
use bencher_json::{JsonNewProject, JsonProject, JsonProjects, ProjectUuid};
use bencher_json::{JsonNewProject, JsonProject, JsonProjects, ParameterFilter, ProjectUuid};
use http::StatusCode;

// GET /v0/projects - list all public projects
Expand Down Expand Up @@ -1293,3 +1293,172 @@ async fn projects_update_private_for_a_paid_org_past_its_daily_metrics_limit() {
let resp_body = resp.text().await.expect("Failed to read response body");
assert_eq!(status, StatusCode::OK, "{resp_body}");
}

// A plot's parameters filter is stored in its canonical form.
#[tokio::test]
async fn plot_parameters_round_trip_canonical() {
use bencher_api_tests::helpers::get_project_id;

let server = TestServer::new().await;
let user = server
.signup("Plot User Params", "plotparams@example.com")
.await;
let org = server.create_org(&user, "Plot Org Params").await;
let project = server
.create_project(&user, &org, "Plot Project Params")
.await;
let project_slug: &str = project.slug.as_ref();
let project_id = get_project_id(&server, project_slug);

let dims = seed_plot_dimensions(&server, project_id);
// Scrambled order and two spellings of one number go in.
let created = post_plot(
&server,
&user,
project_slug,
&serde_json::json!({
"lower_value": true,
"upper_value": true,
"lower_boundary": false,
"upper_boundary": false,
"x_axis": "date_time",
"window": 2_592_000,
"branches": [dims.branch1.to_string()],
"testbeds": [dims.testbed.to_string()],
"benchmarks": [dims.benchmark.to_string()],
"parameters": [
{ "size": 2 },
{ "size": 1.0 },
{ "size": 1 },
],
"measures": [dims.measure.to_string()],
}),
)
.await;
// The canonical form comes back: sorted by canonical bytes, deduplicated.
let parameters = created.parameters.as_ref().expect("plot has a filter");
assert_eq!(parameters.canonical(), r#"[{"size":1},{"size":2}]"#);

// A read back through GET reports the same canonical filter.
let fetched: bencher_json::JsonPlot = server
.client
.get(server.api_url(&format!(
"/v0/projects/{project_slug}/plots/{}",
created.uuid
)))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.send()
.await
.expect("Request failed")
.json()
.await
.expect("Failed to parse plot");
assert_eq!(
fetched.parameters.as_ref().map(ParameterFilter::canonical),
Some(r#"[{"size":1},{"size":2}]"#.to_owned())
);

// A patch replaces the filter wholesale.
let patch = serde_json::json!({ "parameters": [{ "size": 4, "threads": 8 }] });
let updated = patch_plot(&server, &user, project_slug, created.uuid, &patch).await;
assert_eq!(
updated.parameters.as_ref().map(ParameterFilter::canonical),
Some(r#"[{"size":4,"threads":8}]"#.to_owned())
);

// A patch that says nothing about the filter leaves it alone.
let untouched = patch_plot(
&server,
&user,
project_slug,
created.uuid,
&serde_json::json!({ "upper_boundary": true }),
)
.await;
assert!(untouched.upper_boundary);
assert_eq!(
untouched
.parameters
.as_ref()
.map(ParameterFilter::canonical),
Some(r#"[{"size":4,"threads":8}]"#.to_owned())
);
}

// A filter that matches every variant is no filter at all: `null`, the empty
// list, and the list holding the empty set are one stored state, and so is a plot
// created without a filter at all.
#[tokio::test]
async fn plot_parameters_match_all_is_absent() {
use bencher_api_tests::helpers::get_project_id;

let server = TestServer::new().await;
let user = server
.signup("Plot User Match All", "plotmatchall@example.com")
.await;
let org = server.create_org(&user, "Plot Org Match All").await;
let project = server
.create_project(&user, &org, "Plot Project Match All")
.await;
let project_slug: &str = project.slug.as_ref();
let project_id = get_project_id(&server, project_slug);

let dims = seed_plot_dimensions(&server, project_id);
let new_plot = |parameters: Option<serde_json::Value>| {
let mut plot = serde_json::json!({
"lower_value": true,
"upper_value": true,
"lower_boundary": false,
"upper_boundary": false,
"x_axis": "date_time",
"window": 2_592_000,
"branches": [dims.branch1.to_string()],
"testbeds": [dims.testbed.to_string()],
"benchmarks": [dims.benchmark.to_string()],
"measures": [dims.measure.to_string()],
});
if let Some(parameters) = parameters {
plot["parameters"] = parameters;
}
plot
};

// Absent, the empty list, and the list holding the empty set all create a plot
// with no filter, and the response leaves the field out.
for parameters in [
None,
Some(serde_json::json!([])),
Some(serde_json::json!([{}])),
Some(serde_json::Value::Null),
] {
let created = post_plot(&server, &user, project_slug, &new_plot(parameters)).await;
assert!(created.parameters.is_none());
let body = serde_json::to_value(&created).expect("Failed to serialize plot");
assert!(body.get("parameters").is_none());
}

// A plot that names a filter goes back to every variant on a `null` patch,
// and the same way on an empty list patch.
for clear in [serde_json::Value::Null, serde_json::json!([])] {
let created = post_plot(
&server,
&user,
project_slug,
&new_plot(Some(serde_json::json!([{ "size": 1 }]))),
)
.await;
assert!(created.parameters.is_some());
let cleared = patch_plot(
&server,
&user,
project_slug,
created.uuid,
&serde_json::json!({ "parameters": clear }),
)
.await;
assert!(cleared.parameters.is_none());
}
}
64 changes: 63 additions & 1 deletion lib/bencher_json/src/project/plot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use serde::{
de::{self, Visitor},
};

use crate::{BenchmarkUuid, BranchUuid, MeasureUuid, ProjectUuid, TestbedUuid};
use crate::{BenchmarkUuid, BranchUuid, MeasureUuid, ParameterFilter, ProjectUuid, TestbedUuid};

crate::typed_uuid::typed_uuid!(PlotUuid);

Expand Down Expand Up @@ -52,6 +52,11 @@ pub struct JsonNewPlot {
/// The benchmarks to include in the plot.
/// At least one benchmark must be specified.
pub benchmarks: Vec<BenchmarkUuid>,
/// The variants to include in the plot, as a parameters filter.
/// A variant matches when any entry in the filter is a subset of its parameters.
/// If not set, or set to an empty list, the plot includes every variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parameters: Option<ParameterFilter>,
/// The measures to include in the plot.
/// At least one measure must be specified.
pub measures: Vec<MeasureUuid>,
Expand Down Expand Up @@ -84,6 +89,10 @@ pub struct JsonPlot {
pub branches: Vec<BranchUuid>,
pub testbeds: Vec<TestbedUuid>,
pub benchmarks: Vec<BenchmarkUuid>,
/// The variants this plot draws, in canonical order.
/// Absent when the plot draws every variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parameters: Option<ParameterFilter>,
pub measures: Vec<MeasureUuid>,
pub created: DateTime,
pub modified: DateTime,
Expand Down Expand Up @@ -134,6 +143,11 @@ pub struct JsonPlotPatch {
/// Replaces the current benchmarks for the plot.
/// At least one benchmark must be specified.
pub benchmarks: Option<Vec<BenchmarkUuid>>,
/// The variants to include in the plot, as a parameters filter.
/// Replaces the current filter for the plot.
/// Set to `null` or to an empty list to include every variant again.
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<ParameterFilter>,
/// The measures to include in the plot.
/// Replaces the current measures for the plot.
/// At least one measure must be specified.
Expand All @@ -155,6 +169,8 @@ pub struct JsonPlotPatchNull {
pub branches: Option<Vec<BranchUuid>>,
pub testbeds: Option<Vec<TestbedUuid>>,
pub benchmarks: Option<Vec<BenchmarkUuid>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<ParameterFilter>,
pub measures: Option<Vec<MeasureUuid>>,
}

Expand All @@ -176,6 +192,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot {
const BRANCHES_FIELD: &str = "branches";
const TESTBEDS_FIELD: &str = "testbeds";
const BENCHMARKS_FIELD: &str = "benchmarks";
const PARAMETERS_FIELD: &str = "parameters";
const MEASURES_FIELD: &str = "measures";
const FIELDS: &[&str] = &[
INDEX_FIELD,
Expand All @@ -190,6 +207,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot {
BRANCHES_FIELD,
TESTBEDS_FIELD,
BENCHMARKS_FIELD,
PARAMETERS_FIELD,
MEASURES_FIELD,
];

Expand All @@ -208,6 +226,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot {
Branches,
Testbeds,
Benchmarks,
Parameters,
Measures,
}

Expand Down Expand Up @@ -237,6 +256,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot {
let mut branches = None;
let mut testbeds = None;
let mut benchmarks = None;
let mut parameters: Option<Option<ParameterFilter>> = None;
let mut measures = None;

while let Some(key) = map.next_key()? {
Expand Down Expand Up @@ -313,6 +333,12 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot {
}
benchmarks = Some(map.next_value()?);
},
Field::Parameters => {
if parameters.is_some() {
return Err(de::Error::duplicate_field(PARAMETERS_FIELD));
}
parameters = Some(map.next_value()?);
},
Field::Measures => {
if measures.is_some() {
return Err(de::Error::duplicate_field(MEASURES_FIELD));
Expand All @@ -322,6 +348,9 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot {
}
}

// An explicit null clears the filter, as an empty list does.
let parameters = parameters.map(Option::unwrap_or_default);

Ok(match title {
Some(Some(title)) => Self::Value::Patch(JsonPlotPatch {
index,
Expand All @@ -336,6 +365,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot {
branches,
testbeds,
benchmarks,
parameters,
measures,
}),
Some(None) => Self::Value::Null(JsonPlotPatchNull {
Expand All @@ -351,6 +381,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot {
branches,
testbeds,
benchmarks,
parameters,
measures,
}),
None => Self::Value::Patch(JsonPlotPatch {
Expand All @@ -366,6 +397,7 @@ impl<'de> Deserialize<'de> for JsonUpdatePlot {
branches,
testbeds,
benchmarks,
parameters,
measures,
}),
})
Expand Down Expand Up @@ -575,6 +607,7 @@ mod tests {
assert!(patch.branches.is_none());
assert!(patch.testbeds.is_none());
assert!(patch.benchmarks.is_none());
assert!(patch.parameters.is_none());
assert!(patch.measures.is_none());
}

Expand Down Expand Up @@ -660,4 +693,33 @@ mod tests {
serde_json::from_str::<JsonUpdatePlot>(r#"{"lower_value": true, "lower_value": false}"#)
.unwrap_err();
}

#[test]
fn deserialize_null_and_empty_parameters_both_clear() {
for body in [r#"{"parameters": null}"#, r#"{"parameters": []}"#] {
let update: JsonUpdatePlot = serde_json::from_str(body).unwrap();
let JsonUpdatePlot::Patch(patch) = update else {
panic!("expected Patch variant");
};
let parameters = patch.parameters.expect("parameters was written");
assert!(parameters.is_match_all(), "{body}");
}
}

#[test]
fn deserialize_duplicate_parameters_field_errors() {
serde_json::from_str::<JsonUpdatePlot>(r#"{"parameters": [], "parameters": []}"#)
.unwrap_err();
}

#[test]
fn deserialize_null_title_carries_parameters() {
let update: JsonUpdatePlot =
serde_json::from_str(r#"{"title": null, "parameters": [{"size": 1}]}"#).unwrap();
let JsonUpdatePlot::Null(patch) = update else {
panic!("expected Null variant");
};
let parameters = patch.parameters.expect("parameters was written");
assert_eq!(parameters.canonical(), r#"[{"size":1}]"#);
}
}
Loading
Loading