use ridl_ir::v2;
use crate::{Category, Change, Verdict, diff_packages, diff_sets, render_json};
fn interaction(name: &str, ordinal: u32, kind: v2::decl::Kind) -> v2::Decl {
v2::Decl {
limit: None,
name: name.to_string(),
visibility: v2::Visibility::Unspecified as i32,
is_error: false,
doc: String::new(),
labels: Vec::new(),
deprecated: None,
ordinal,
kind: Some(kind),
links: Vec::new(),
see: Vec::new(),
since: Vec::new(),
}
}
fn signal(name: &str, ordinal: u32, payload: &str) -> v2::Decl {
interaction(
name,
ordinal,
v2::decl::Kind::SignalDef(v2::SignalDef {
payload: payload.to_string(),
declared_init: None,
init: None,
timing: None,
family: ridl_ir::v2::Family::Dispatch as i32,
}),
)
}
fn event(name: &str, ordinal: u32, payload: &str) -> v2::Decl {
interaction(
name,
ordinal,
v2::decl::Kind::EventDef(v2::EventDef {
payload: payload.to_string(),
timing: None,
family: ridl_ir::v2::Family::Dispatch as i32,
}),
)
}
fn reserved(name: &str, ordinal: u32) -> v2::Decl {
v2::Decl {
limit: None,
name: String::new(),
visibility: v2::Visibility::Unspecified as i32,
is_error: false,
doc: String::new(),
labels: Vec::new(),
deprecated: None,
ordinal,
kind: Some(v2::decl::Kind::ReservedSlot(v2::Reserved {
ordinal,
name: Some(name.to_string()),
value: None,
})),
links: Vec::new(),
see: Vec::new(),
since: Vec::new(),
}
}
fn interface(name: &str, interactions: Vec<v2::Decl>) -> v2::Interface {
v2::Interface {
name: name.to_string(),
visibility: v2::Visibility::Public as i32,
doc: String::new(),
labels: Vec::new(),
deprecated: None,
interactions,
number: 0,
provisional: false,
links: Vec::new(),
see: Vec::new(),
since: Vec::new(),
}
}
fn pkg(name: &str, iface: v2::Interface) -> v2::Package {
v2::Package {
name: name.to_string(),
decls: Vec::new(),
interfaces: vec![iface],
services: Vec::new(),
retired: Vec::new(),
unit: String::new(),
}
}
#[test]
fn a_limit_only_change_reports_exactly_one_breaking_change() {
let mut old_signal = signal("a", 1, "T");
old_signal.limit = None;
let mut new_signal = signal("a", 1, "T");
new_signal.limit = Some(64);
let old = pkg("veh.cluster", interface("I", vec![old_signal]));
let new = pkg("veh.cluster", interface("I", vec![new_signal]));
let report = diff_packages(&old, &new);
assert_eq!(report.changes.len(), 1, "{:?}", report.changes);
let change = &report.changes[0];
assert_eq!(change.category, Category::LimitChanged);
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(change.before.as_deref(), Some("none"));
assert_eq!(change.after.as_deref(), Some("64"));
assert_eq!(crate::heading(Category::LimitChanged), None);
}
#[test]
fn a_kind_change_that_also_changes_the_limit_reports_only_the_kind_change() {
let mut old_signal = signal("a", 1, "T");
old_signal.limit = Some(64);
let mut new_event = event("a", 1, "T");
new_event.limit = Some(128);
let old = pkg("veh.cluster", interface("I", vec![old_signal]));
let new = pkg("veh.cluster", interface("I", vec![new_event]));
let report = diff_packages(&old, &new);
assert_eq!(report.changes.len(), 1, "{:?}", report.changes);
assert_eq!(report.changes[0].category, Category::KindChanged);
}
#[test]
fn identical_snapshots_report_identical() {
let iface = interface(
"VehicleStatus",
vec![
signal("currentSpeed", 1, "Speed"),
event("doorOpened", 2, "DoorEvent"),
],
);
let old = pkg("veh.cluster", iface.clone());
let new = pkg("veh.cluster", iface);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Identical);
assert!(
report.changes.is_empty(),
"no differences, got {:?}",
report.changes
);
}
#[test]
fn a_doc_edit_is_doc_only_and_compatible() {
let old = pkg(
"veh.cluster",
interface("VehicleStatus", vec![signal("currentSpeed", 1, "Speed")]),
);
let mut new = old.clone();
new.interfaces[0].interactions[0].doc = "the current road speed".to_string();
let report = diff_packages(&old, &new);
assert_eq!(
report.changes.len(),
1,
"one change, got {:?}",
report.changes
);
assert_eq!(report.changes[0].category, Category::DocOnly);
assert_eq!(
report.changes[0].path,
"veh.cluster/VehicleStatus/currentSpeed"
);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn appending_an_interaction_is_compatible() {
let old = pkg(
"veh.cluster",
interface("VehicleStatus", vec![signal("currentSpeed", 1, "Speed")]),
);
let new = pkg(
"veh.cluster",
interface(
"VehicleStatus",
vec![
signal("currentSpeed", 1, "Speed"),
event("doorOpened", 2, "DoorEvent"),
],
),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes.len(),
1,
"one change, got {:?}",
report.changes
);
assert_eq!(report.changes[0].category, Category::InteractionAppended);
assert_eq!(
report.changes[0].path,
"veh.cluster/VehicleStatus/doorOpened"
);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn a_payload_type_change_is_breaking() {
let old = pkg(
"veh.cluster",
interface("VehicleStatus", vec![event("doorOpened", 1, "DoorEvent")]),
);
let new = pkg(
"veh.cluster",
interface("VehicleStatus", vec![event("doorOpened", 1, "DoorState")]),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes.len(),
1,
"one change, got {:?}",
report.changes
);
let change = &report.changes[0];
assert_eq!(change.category, Category::PayloadChanged);
assert_eq!(change.path, "veh.cluster/VehicleStatus/doorOpened");
assert_eq!(change.before.as_deref(), Some("DoorEvent"));
assert_eq!(change.after.as_deref(), Some("DoorState"));
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn removing_without_a_tombstone_is_breaking() {
let old = pkg(
"veh.cluster",
interface(
"VehicleStatus",
vec![
signal("currentSpeed", 1, "Speed"),
event("doorOpened", 2, "DoorEvent"),
],
),
);
let new = pkg(
"veh.cluster",
interface("VehicleStatus", vec![signal("currentSpeed", 1, "Speed")]),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes.len(),
1,
"one change, got {:?}",
report.changes
);
assert_eq!(report.changes[0].category, Category::InteractionRemoved);
assert_eq!(
report.changes[0].path,
"veh.cluster/VehicleStatus/doorOpened"
);
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn removing_with_a_tombstone_is_compatible() {
let old = pkg(
"veh.cluster",
interface(
"VehicleStatus",
vec![
signal("currentSpeed", 1, "Speed"),
event("doorOpened", 2, "DoorEvent"),
],
),
);
let new = pkg(
"veh.cluster",
interface(
"VehicleStatus",
vec![
signal("currentSpeed", 1, "Speed"),
reserved("doorOpened", 2),
],
),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes.len(),
1,
"one change, got {:?}",
report.changes
);
assert_eq!(report.changes[0].category, Category::InteractionRetired);
assert_eq!(
report.changes[0].path,
"veh.cluster/VehicleStatus/doorOpened"
);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn a_new_package_is_compatible_and_a_dropped_one_is_breaking() {
let a = pkg("veh.a", interface("A", vec![signal("s", 1, "Speed")]));
let b = pkg("veh.b", interface("B", vec![signal("s", 1, "Speed")]));
let added = diff_sets(std::slice::from_ref(&a), &[a.clone(), b.clone()]);
assert_eq!(
added.changes.len(),
1,
"one change, got {:?}",
added.changes
);
assert_eq!(added.changes[0].category, Category::DeclAdded);
assert_eq!(added.changes[0].path, "veh.b");
assert_eq!(added.verdict, Verdict::Compatible);
let removed = diff_sets(&[a.clone(), b.clone()], std::slice::from_ref(&a));
assert_eq!(
removed.changes.len(),
1,
"one change, got {:?}",
removed.changes
);
assert_eq!(removed.changes[0].category, Category::DeclRemoved);
assert_eq!(removed.changes[0].path, "veh.b");
assert_eq!(removed.verdict, Verdict::Breaking);
}
#[test]
fn reordering_interactions_is_breaking() {
let old = pkg(
"veh.cluster",
interface(
"I",
vec![
signal("a", 1, "T"),
signal("b", 2, "T"),
signal("c", 3, "T"),
],
),
);
let new = pkg(
"veh.cluster",
interface(
"I",
vec![
signal("a", 1, "T"),
signal("c", 2, "T"),
signal("b", 3, "T"),
],
),
);
let report = diff_packages(&old, &new);
let reorders: Vec<_> = report
.changes
.iter()
.filter(|change| change.category == Category::InteractionReordered)
.collect();
assert_eq!(
reorders.len(),
2,
"both moved names flagged, got {:?}",
report.changes
);
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn inserting_before_the_end_is_breaking_without_reorder_noise() {
let old = pkg(
"veh.cluster",
interface("I", vec![signal("a", 1, "T"), signal("b", 2, "T")]),
);
let new = pkg(
"veh.cluster",
interface(
"I",
vec![
signal("a", 1, "T"),
signal("x", 2, "T"),
signal("b", 3, "T"),
],
),
);
let report = diff_packages(&old, &new);
let inserted: Vec<_> = report
.changes
.iter()
.filter(|change| change.category == Category::InteractionInserted)
.collect();
assert_eq!(inserted.len(), 1, "one insert, got {:?}", report.changes);
assert_eq!(inserted[0].path, "veh.cluster/I/x");
assert!(
report
.changes
.iter()
.all(|change| change.category != Category::InteractionReordered),
"no reorder noise, got {:?}",
report.changes
);
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn redeclaring_a_reserved_name_is_breaking() {
let old = pkg(
"veh.cluster",
interface("I", vec![signal("a", 1, "T"), reserved("b", 2)]),
);
let new = pkg(
"veh.cluster",
interface("I", vec![signal("a", 1, "T"), signal("b", 2, "T")]),
);
let report = diff_packages(&old, &new);
assert!(
report
.changes
.iter()
.any(|change| change.category == Category::ReservedNameRedeclared),
"reserved name redeclared, got {:?}",
report.changes
);
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn a_kind_change_is_breaking() {
let old = pkg(
"veh.cluster",
interface("I", vec![signal("doorOpened", 1, "DoorEvent")]),
);
let new = pkg(
"veh.cluster",
interface("I", vec![event("doorOpened", 1, "DoorEvent")]),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes.len(),
1,
"one change, got {:?}",
report.changes
);
assert_eq!(report.changes[0].category, Category::KindChanged);
assert_eq!(report.changes[0].before.as_deref(), Some("signal"));
assert_eq!(report.changes[0].after.as_deref(), Some("event"));
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn render_json_matches_the_stable_schema() {
let old = pkg(
"veh.cluster",
interface("VehicleStatus", vec![event("doorOpened", 1, "DoorEvent")]),
);
let new = pkg(
"veh.cluster",
interface("VehicleStatus", vec![event("doorOpened", 1, "DoorState")]),
);
let report = diff_packages(&old, &new);
let value: serde_json::Value =
serde_json::from_str(&render_json(&report)).expect("render_json emits valid JSON");
assert_eq!(value["verdict"], "breaking");
let changes = value["changes"].as_array().expect("changes is an array");
assert_eq!(changes.len(), 1);
let change = &changes[0];
assert_eq!(change["path"], "veh.cluster/VehicleStatus/doorOpened");
assert_eq!(change["category"], "payload_changed");
assert_eq!(change["verdict"], "breaking");
assert_eq!(change["before"], "DoorEvent");
assert_eq!(change["after"], "DoorState");
}
#[test]
fn a_tombstone_written_out_of_its_slot_is_breaking() {
let old = pkg(
"veh.cluster",
interface(
"I",
vec![
signal("a", 1, "T"),
signal("b", 2, "T"),
signal("c", 3, "T"),
],
),
);
let new = pkg(
"veh.cluster",
interface(
"I",
vec![signal("a", 1, "T"), signal("c", 2, "T"), reserved("b", 3)],
),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.verdict,
Verdict::Breaking,
"an out-of-slot tombstone frees a wire slot, got {:?}",
report.changes
);
assert!(
report
.changes
.iter()
.all(|change| change.category != Category::InteractionRetired),
"an out-of-slot tombstone is not a compatible retirement, got {:?}",
report.changes
);
}
#[test]
fn dropping_a_tombstone_is_breaking() {
let old = pkg(
"veh.cluster",
interface(
"I",
vec![signal("a", 1, "T"), reserved("b", 2), signal("c", 3, "T")],
),
);
let new = pkg(
"veh.cluster",
interface("I", vec![signal("a", 1, "T"), signal("c", 2, "T")]),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.verdict,
Verdict::Breaking,
"a dropped tombstone frees a wire slot, got {:?}",
report.changes
);
assert!(
report.changes.iter().any(|change| {
change.category == Category::InteractionRemoved && change.path == "veh.cluster/I/b"
}),
"the dropped tombstone is itself a change, got {:?}",
report.changes
);
}
#[test]
fn moving_a_tombstone_is_breaking() {
let old = pkg(
"veh.cluster",
interface(
"I",
vec![signal("a", 1, "T"), reserved("b", 2), signal("c", 3, "T")],
),
);
let new = pkg(
"veh.cluster",
interface(
"I",
vec![signal("a", 1, "T"), signal("c", 2, "T"), reserved("b", 3)],
),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.verdict,
Verdict::Breaking,
"a moved tombstone shifts wire identities, got {:?}",
report.changes
);
}
#[test]
fn a_tombstone_written_in_its_slot_is_compatible() {
let old = pkg(
"veh.cluster",
interface(
"I",
vec![
signal("a", 1, "T"),
signal("b", 2, "T"),
signal("c", 3, "T"),
],
),
);
let new = pkg(
"veh.cluster",
interface(
"I",
vec![signal("a", 1, "T"), reserved("b", 2), signal("c", 3, "T")],
),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes.len(),
1,
"one change, got {:?}",
report.changes
);
assert_eq!(report.changes[0].category, Category::InteractionRetired);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn retiring_the_last_interaction_is_compatible() {
let old = pkg(
"veh.cluster",
interface("I", vec![signal("a", 1, "T"), signal("b", 2, "T")]),
);
let new = pkg(
"veh.cluster",
interface("I", vec![signal("a", 1, "T"), reserved("b", 2)]),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes.len(),
1,
"one change, got {:?}",
report.changes
);
assert_eq!(report.changes[0].category, Category::InteractionRetired);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn an_unchanged_tombstone_is_identical() {
let iface = interface(
"I",
vec![signal("a", 1, "T"), reserved("b", 2), signal("c", 3, "T")],
);
let old = pkg("veh.cluster", iface.clone());
let new = pkg("veh.cluster", iface);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Identical);
assert!(report.changes.is_empty(), "got {:?}", report.changes);
}
#[test]
fn a_fresh_tombstone_before_the_end_is_breaking() {
let old = pkg(
"veh.cluster",
interface("I", vec![signal("a", 1, "T"), signal("b", 2, "T")]),
);
let new = pkg(
"veh.cluster",
interface(
"I",
vec![signal("a", 1, "T"), reserved("z", 2), signal("b", 3, "T")],
),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.verdict,
Verdict::Breaking,
"a fresh mid-body tombstone shifts later ordinals, got {:?}",
report.changes
);
assert!(
report.changes.iter().any(|change| {
change.category == Category::InteractionInserted && change.path == "veh.cluster/I/z"
}),
"the minted tombstone is reported at its own path, got {:?}",
report.changes
);
}
#[test]
fn a_fresh_tombstone_at_the_end_is_compatible() {
let old = pkg(
"veh.cluster",
interface("I", vec![signal("a", 1, "T"), signal("b", 2, "T")]),
);
let new = pkg(
"veh.cluster",
interface(
"I",
vec![signal("a", 1, "T"), signal("b", 2, "T"), reserved("z", 3)],
),
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes.len(),
1,
"one change, got {:?}",
report.changes
);
assert_eq!(report.changes[0].category, Category::InteractionAppended);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn a_compatible_delta_moves_no_projected_name() {
let base = vec![
signal("currentSpeed", 1, "Speed"),
signal("parseHTTPResponse", 2, "Ratio"),
];
let appended = {
let mut v = base.clone();
v.push(signal("wheelTicks", 3, "Ratio"));
v
};
let retired = vec![
signal("currentSpeed", 1, "Speed"),
reserved("parseHTTPResponse", 2),
];
for (label, new_members) in [("append", appended), ("retire", retired)] {
let old = pkg("veh.cluster", interface("VehicleStatus", base.clone()));
let new = pkg("veh.cluster", interface("VehicleStatus", new_members));
let report = diff_packages(&old, &new);
assert_eq!(
report.verdict,
Verdict::Compatible,
"{label}: fixture is not a compatible delta"
);
for old_member in &old.interfaces[0].interactions {
let survivor = new.interfaces[0]
.interactions
.iter()
.find(|m| m.name == old_member.name && !m.name.is_empty());
if let Some(survivor) = survivor {
assert_eq!(
ridl_ir::name::snake_case(&old_member.name),
ridl_ir::name::snake_case(&survivor.name),
"{label}: `{}` changed projection under a compatible delta",
old_member.name
);
}
}
}
let renamed = vec![
signal("vehicleSpeed", 1, "Speed"),
signal("parseHTTPResponse", 2, "Ratio"),
];
let old = pkg("veh.cluster", interface("VehicleStatus", base.clone()));
let new = pkg("veh.cluster", interface("VehicleStatus", renamed));
assert_eq!(
diff_packages(&old, &new).verdict,
Verdict::Breaking,
"a rename must classify breaking, or the invariant above is vacuous"
);
assert_ne!(
ridl_ir::name::snake_case("currentSpeed"),
ridl_ir::name::snake_case("vehicleSpeed"),
"the rename must move the projection, or this arm proves nothing"
);
}
fn frozen(name: &str, number: u32, interactions: Vec<v2::Decl>) -> v2::Interface {
v2::Interface {
number,
provisional: false,
..interface(name, interactions)
}
}
fn provisional(name: &str, number: u32, interactions: Vec<v2::Decl>) -> v2::Interface {
v2::Interface {
number,
provisional: true,
..interface(name, interactions)
}
}
fn package(
interfaces: Vec<v2::Interface>,
services: Vec<v2::Service>,
retired: Vec<v2::RetiredInterface>,
) -> v2::Package {
v2::Package {
name: "veh.cluster".to_string(),
decls: Vec::new(),
interfaces,
services,
retired,
unit: String::new(),
}
}
fn inline_service(name: &str, number: u32, interactions: Vec<v2::Decl>) -> v2::Service {
v2::Service {
name: name.to_string(),
visibility: v2::Visibility::Public as i32,
doc: String::new(),
labels: Vec::new(),
deprecated: None,
shapes: vec![v2::ServiceShape {
kind: Some(v2::service_shape::Kind::Inline(v2::Interface {
visibility: v2::Visibility::Unspecified as i32,
..frozen("", number, interactions)
})),
}],
links: Vec::new(),
see: Vec::new(),
since: Vec::new(),
}
}
fn retired(name: &str, number: u32) -> v2::RetiredInterface {
v2::RetiredInterface {
name: name.to_string(),
number,
}
}
fn change(
path: &str,
category: Category,
verdict: Verdict,
before: Option<&str>,
after: Option<&str>,
) -> Change {
Change {
path: path.to_string(),
category,
verdict,
before: before.map(str::to_string),
after: after.map(str::to_string),
}
}
fn door_opened(payload: &str) -> Vec<v2::Decl> {
vec![event("doorOpened", 1, payload)]
}
#[test]
fn a_matched_number_diffs_the_interactions_as_today() {
let old = package(
vec![frozen("Doors", 1, door_opened("DoorEvent"))],
vec![],
vec![],
);
let new = package(
vec![frozen("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/Doors/doorOpened",
Category::PayloadChanged,
Verdict::Breaking,
Some("DoorEvent"),
Some("DoorState"),
)]
);
}
#[test]
fn a_rename_on_the_same_number_is_compatible_under_the_heading() {
let old = package(
vec![frozen("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let new = package(
vec![frozen("DoorStatus", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/DoorStatus",
Category::InterfaceRenamed,
Verdict::Compatible,
Some("Doors"),
Some("DoorStatus"),
)]
);
assert_eq!(
crate::heading(Category::InterfaceRenamed),
Some("compatible on the wire, visible in source")
);
assert_eq!(crate::heading(Category::InterfaceRetired), None);
}
#[test]
fn a_fresh_frozen_number_is_decl_added() {
let old = package(
vec![frozen("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let new = package(
vec![
frozen("Doors", 1, door_opened("DoorState")),
frozen("Lights", 2, vec![event("on", 1, "DoorState")]),
],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/Lights",
Category::DeclAdded,
Verdict::Compatible,
None,
Some("interface"),
)]
);
}
#[test]
fn a_provisional_interface_is_decl_added_and_never_matched() {
let old = package(
vec![frozen("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let new = package(
vec![provisional("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
report.changes,
vec![
change(
"veh.cluster/Doors",
Category::DeclRemoved,
Verdict::Breaking,
Some("interface"),
None,
),
change(
"veh.cluster/Doors",
Category::DeclAdded,
Verdict::Compatible,
None,
Some("interface"),
),
]
);
}
#[test]
fn a_number_in_the_retired_list_is_interface_retired() {
let lights = || frozen("Lights", 2, vec![event("on", 1, "DoorState")]);
let old = package(
vec![frozen("Doors", 1, door_opened("DoorState")), lights()],
vec![],
vec![],
);
let new = package(vec![lights()], vec![], vec![retired("Doors", 1)]);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/Doors",
Category::InterfaceRetired,
Verdict::Compatible,
Some("interface"),
Some("retired"),
)]
);
}
#[test]
fn a_number_absent_and_not_retired_is_decl_removed() {
let lights = || frozen("Lights", 2, vec![event("on", 1, "DoorState")]);
let old = package(
vec![frozen("Doors", 1, door_opened("DoorState")), lights()],
vec![],
vec![],
);
let new = package(vec![lights()], vec![], vec![]);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/Doors",
Category::DeclRemoved,
Verdict::Breaking,
Some("interface"),
None,
)]
);
}
#[test]
fn a_hand_changed_number_is_removed_plus_added() {
let old = package(
vec![frozen("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let new = package(
vec![frozen("Doors", 2, door_opened("DoorState"))],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
report.changes,
vec![
change(
"veh.cluster/Doors",
Category::DeclRemoved,
Verdict::Breaking,
Some("interface"),
None,
),
change(
"veh.cluster/Doors",
Category::DeclAdded,
Verdict::Compatible,
None,
Some("interface"),
),
]
);
}
#[test]
fn a_published_number_zero_is_matched_by_name() {
let old = package(
vec![interface("Doors", door_opened("DoorEvent"))],
vec![],
vec![],
);
let new = package(
vec![frozen("Doors", 3, door_opened("DoorState"))],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/Doors/doorOpened",
Category::PayloadChanged,
Verdict::Breaking,
Some("DoorEvent"),
Some("DoorState"),
)]
);
}
#[test]
fn a_provisional_old_side_is_matched_by_name() {
let old = package(
vec![provisional("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let new = package(
vec![
provisional("Aux", 1, vec![event("on", 1, "DoorState")]),
provisional(
"Doors",
2,
vec![
event("doorOpened", 1, "DoorState"),
event("doorClosed", 2, "DoorState"),
],
),
],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
report.changes,
vec![
change(
"veh.cluster/Doors",
Category::InterfaceNumberChanged,
Verdict::Breaking,
Some("1 (provisional)"),
Some("2 (provisional)"),
),
change(
"veh.cluster/Doors/doorClosed",
Category::InteractionAppended,
Verdict::Compatible,
None,
Some("event doorClosed"),
),
change(
"veh.cluster/Aux",
Category::DeclAdded,
Verdict::Compatible,
None,
Some("interface"),
),
]
);
}
#[test]
fn a_provisional_number_frozen_in_place_is_interface_frozen() {
let old = package(
vec![provisional("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let new = package(
vec![frozen("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/Doors",
Category::InterfaceFrozen,
Verdict::Compatible,
Some("1 (provisional)"),
Some("1"),
)]
);
assert_eq!(
crate::heading(Category::InterfaceFrozen),
Some("compatible on the wire, may change the catalog hash")
);
assert_eq!(crate::heading(Category::InterfaceNumberChanged), None);
}
#[test]
fn a_provisional_number_frozen_as_another_number_is_interface_number_changed() {
let old = package(
vec![provisional("Doors", 2, door_opened("DoorState"))],
vec![],
vec![],
);
let new = package(
vec![frozen("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/Doors",
Category::InterfaceNumberChanged,
Verdict::Breaking,
Some("2 (provisional)"),
Some("1"),
)]
);
}
#[test]
fn an_old_number_zero_is_not_compared() {
let old = package(
vec![interface("Doors", door_opened("DoorState"))],
vec![],
vec![],
);
for new_interface in [
provisional("Doors", 2, door_opened("DoorState")),
frozen("Doors", 3, door_opened("DoorState")),
] {
let new = package(vec![new_interface], vec![], vec![]);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Identical);
assert_eq!(report.changes, vec![]);
}
}
#[test]
fn an_unchanged_provisional_number_is_identical() {
let side = || {
package(
vec![provisional("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
)
};
let report = diff_packages(&side(), &side());
assert_eq!(report.verdict, Verdict::Identical);
assert_eq!(report.changes, vec![]);
}
#[test]
fn a_new_number_zero_is_not_compared() {
let new = package(
vec![interface("Doors", door_opened("DoorState"))],
vec![],
vec![],
);
let old = package(
vec![provisional("Doors", 2, door_opened("DoorState"))],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Identical);
assert_eq!(report.changes, vec![]);
}
fn provisional_inline_service(name: &str, number: u32, interactions: Vec<v2::Decl>) -> v2::Service {
let mut service = inline_service(name, number, interactions);
if let Some(v2::service_shape::Kind::Inline(shape)) = &mut service.shapes[0].kind {
shape.provisional = true;
}
service
}
#[test]
fn an_inline_shape_frozen_in_place_is_interface_frozen() {
let old = package(
vec![],
vec![provisional_inline_service(
"veh.cluster.hvac",
5,
door_opened("DoorState"),
)],
vec![],
);
let new = package(
vec![],
vec![inline_service(
"veh.cluster.hvac",
5,
door_opened("DoorState"),
)],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/veh.cluster.hvac",
Category::InterfaceFrozen,
Verdict::Compatible,
Some("5 (provisional)"),
Some("5"),
)]
);
}
#[test]
fn an_inline_shape_frozen_as_another_number_is_interface_number_changed() {
let old = package(
vec![],
vec![provisional_inline_service(
"veh.cluster.hvac",
6,
door_opened("DoorState"),
)],
vec![],
);
let new = package(
vec![],
vec![inline_service(
"veh.cluster.hvac",
5,
door_opened("DoorState"),
)],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
report.changes,
vec![change(
"veh.cluster/veh.cluster.hvac",
Category::InterfaceNumberChanged,
Verdict::Breaking,
Some("6 (provisional)"),
Some("5"),
)]
);
}
#[test]
fn an_inline_shape_follows_its_number_across_a_service_rename() {
let old = package(
vec![],
vec![inline_service(
"veh.cluster.hvac",
5,
vec![event("cabinTemp", 1, "DoorState")],
)],
vec![],
);
let new = package(
vec![],
vec![inline_service(
"veh.cluster.climate",
5,
vec![
event("cabinTemp", 1, "DoorState"),
event("filterClogged", 2, "DoorState"),
],
)],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(
report.changes,
vec![
change(
"veh.cluster/veh.cluster.climate",
Category::InterfaceRenamed,
Verdict::Compatible,
Some("veh.cluster.hvac"),
Some("veh.cluster.climate"),
),
change(
"veh.cluster/veh.cluster.climate/filterClogged",
Category::InteractionAppended,
Verdict::Compatible,
None,
Some("event filterClogged"),
),
change(
"veh.cluster/veh.cluster.hvac",
Category::DeclRemoved,
Verdict::Breaking,
Some("service"),
None,
),
change(
"veh.cluster/veh.cluster.climate",
Category::DeclAdded,
Verdict::Compatible,
None,
Some("service"),
),
]
);
assert_eq!(
report.verdict,
Verdict::Breaking,
"a service is identified by its dotted name, so the rename of the service is breaking \
even though its shape kept its number"
);
}
#[test]
fn a_renamed_interfaces_member_change_classifies_as_before() {
let old = package(
vec![frozen("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let new = package(
vec![frozen(
"DoorStatus",
1,
vec![
event("doorOpened", 1, "DoorState"),
event("doorClosed", 2, "DoorState"),
],
)],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert_eq!(
report.changes,
vec![
change(
"veh.cluster/DoorStatus",
Category::InterfaceRenamed,
Verdict::Compatible,
Some("Doors"),
Some("DoorStatus"),
),
change(
"veh.cluster/DoorStatus/doorClosed",
Category::InteractionAppended,
Verdict::Compatible,
None,
Some("event doorClosed"),
),
]
);
}
#[test]
fn a_headed_change_renders_json_with_no_heading_field() {
let old = package(
vec![frozen("Doors", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let new = package(
vec![frozen("DoorStatus", 1, door_opened("DoorState"))],
vec![],
vec![],
);
let report = diff_packages(&old, &new);
let value: serde_json::Value =
serde_json::from_str(&render_json(&report)).expect("render_json emits valid JSON");
let mut report_keys: Vec<&str> = value
.as_object()
.expect("the report is an object")
.keys()
.map(String::as_str)
.collect();
report_keys.sort_unstable();
assert_eq!(report_keys, ["changes", "verdict"]);
let change = &value["changes"][0];
let mut change_keys: Vec<&str> = change
.as_object()
.expect("a change is an object")
.keys()
.map(String::as_str)
.collect();
change_keys.sort_unstable();
assert_eq!(
change_keys,
["after", "before", "category", "path", "verdict"]
);
assert_eq!(change["category"], "interface_renamed");
}
fn command_with_param() -> v2::Decl {
interaction(
"set",
1,
v2::decl::Kind::CommandDef(v2::CommandDef {
params: vec![v2::Param {
name: "level".to_string(),
r#type: Some(v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Named("Level".to_string())),
}),
..Default::default()
}],
..Default::default()
}),
)
}
fn only_doc_only(report: &crate::DiffReport, path: &str) {
assert_eq!(
report.changes,
vec![change(
path,
Category::DocOnly,
Verdict::Compatible,
None,
None
)],
);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn a_parameter_doc_change_is_doc_only() {
let old = pkg(
"veh.cluster",
interface("Control", vec![command_with_param()]),
);
let mut new = old.clone();
let Some(v2::decl::Kind::CommandDef(def)) = &mut new.interfaces[0].interactions[0].kind else {
panic!("not a command");
};
def.params[0].doc = "the requested level".to_string();
def.params[0].links.push(v2::DocLink {
text: "Level".to_string(),
offset: 0,
len: 5,
target: "veh.cluster.Level".to_string(),
});
only_doc_only(&diff_packages(&old, &new), "veh.cluster/Control/set");
}
#[test]
fn a_since_change_is_doc_only() {
let old = pkg(
"veh.cluster",
interface("Control", vec![command_with_param()]),
);
let mut on_interaction = old.clone();
on_interaction.interfaces[0].interactions[0]
.since
.push("1.2".to_string());
only_doc_only(
&diff_packages(&old, &on_interaction),
"veh.cluster/Control/set",
);
let mut on_interface = old.clone();
on_interface.interfaces[0].since.push("1.2".to_string());
only_doc_only(&diff_packages(&old, &on_interface), "veh.cluster/Control");
}
#[test]
fn a_see_change_on_a_declaration_or_a_service_is_doc_only() {
let see = v2::DocLink {
text: "Other".to_string(),
target: "veh.cluster.Other".to_string(),
..Default::default()
};
let mut old = pkg(
"veh.cluster",
interface("Control", vec![command_with_param()]),
);
old.decls.push(v2::Decl {
name: "Level".to_string(),
kind: Some(v2::decl::Kind::TypeDef(v2::TypeDef::default())),
..Default::default()
});
old.services.push(v2::Service {
name: "veh.cluster.control".to_string(),
..Default::default()
});
let mut on_decl = old.clone();
on_decl.decls[0].see.push(see.clone());
only_doc_only(&diff_packages(&old, &on_decl), "veh.cluster/Level");
let mut on_service = old.clone();
on_service.services[0].see.push(see);
only_doc_only(
&diff_packages(&old, &on_service),
"veh.cluster/veh.cluster.control",
);
}
#[test]
fn a_member_doc_change_is_doc_only() {
let field = v2::Field {
name: "x".to_string(),
ordinal: 1,
..Default::default()
};
let value = v2::EnumValue {
name: "ON".to_string(),
value: 1,
..Default::default()
};
let arm = v2::UnionArm {
name: "a".to_string(),
ordinal: 1,
type_ref: "S".to_string(),
..Default::default()
};
let decl = |name: &str, kind| v2::Decl {
name: name.to_string(),
kind: Some(kind),
..Default::default()
};
let mut old = pkg("veh.cluster", interface("Control", Vec::new()));
old.decls = vec![
decl(
"S",
v2::decl::Kind::StructDef(v2::StructDef {
members: vec![v2::StructMember {
member: Some(v2::struct_member::Member::Field(Box::new(field))),
}],
fixed_layout: false,
}),
),
decl(
"E",
v2::decl::Kind::EnumDef(v2::EnumDef {
values: vec![value.clone()],
..Default::default()
}),
),
decl(
"F",
v2::decl::Kind::EnumSetDef(v2::EnumSetDef {
bits: vec![value],
..Default::default()
}),
),
decl(
"U",
v2::decl::Kind::UnionDef(v2::UnionDef {
arms: vec![arm],
..Default::default()
}),
),
];
let mut on_field = old.clone();
if let Some(v2::decl::Kind::StructDef(def)) = &mut on_field.decls[0].kind
&& let Some(v2::struct_member::Member::Field(field)) = &mut def.members[0].member
{
field.doc = "documented".to_string();
}
only_doc_only(&diff_packages(&old, &on_field), "veh.cluster/S");
let mut on_value = old.clone();
if let Some(v2::decl::Kind::EnumDef(def)) = &mut on_value.decls[1].kind {
def.values[0].since.push("1.2".to_string());
}
only_doc_only(&diff_packages(&old, &on_value), "veh.cluster/E");
let mut on_bit = old.clone();
if let Some(v2::decl::Kind::EnumSetDef(def)) = &mut on_bit.decls[2].kind {
def.bits[0].doc = "documented".to_string();
}
only_doc_only(&diff_packages(&old, &on_bit), "veh.cluster/F");
let mut on_arm = old.clone();
if let Some(v2::decl::Kind::UnionDef(def)) = &mut on_arm.decls[3].kind {
def.arms[0].see.push(v2::DocLink {
text: "S".to_string(),
target: "veh.cluster.S".to_string(),
..Default::default()
});
}
only_doc_only(&diff_packages(&old, &on_arm), "veh.cluster/U");
}
fn in_unit(unit: &str, name: &str, package: v2::Package) -> v2::Package {
v2::Package {
name: name.to_string(),
unit: unit.to_string(),
..package
}
}
#[test]
fn a_frozen_number_moved_to_a_sibling_package_is_a_rename() {
let speed = || frozen("Speed", 1, door_opened("DoorState"));
let old = [
in_unit("u", "u.cluster", package(vec![speed()], vec![], vec![])),
in_unit("u", "u.climate", package(vec![], vec![], vec![])),
];
let new = [
in_unit("u", "u.cluster", package(vec![], vec![], vec![])),
in_unit("u", "u.climate", package(vec![speed()], vec![], vec![])),
];
let report = diff_sets(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![change(
"u.climate/Speed",
Category::InterfaceRenamed,
Verdict::Compatible,
Some("cluster.Speed"),
Some("climate.Speed"),
)]
);
}
#[test]
fn a_moved_interface_has_its_body_compared_and_classified() {
let old = [
in_unit(
"u",
"u.cluster",
package(
vec![frozen("Speed", 1, door_opened("DoorState"))],
vec![],
vec![],
),
),
in_unit("u", "u.climate", package(vec![], vec![], vec![])),
];
let new = [
in_unit("u", "u.cluster", package(vec![], vec![], vec![])),
in_unit(
"u",
"u.climate",
package(
vec![frozen(
"Speed",
1,
vec![
event("doorOpened", 1, "DoorState"),
event("doorClosed", 2, "DoorState"),
],
)],
vec![],
vec![],
),
),
];
let report = diff_sets(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report
.changes
.iter()
.map(|change| (change.path.as_str(), change.category, change.verdict))
.collect::<Vec<_>>(),
vec![
(
"u.climate/Speed",
Category::InterfaceRenamed,
Verdict::Compatible
),
(
"u.climate/Speed/doorClosed",
Category::InteractionAppended,
Verdict::Compatible
),
]
);
}
#[test]
fn a_retired_number_carried_by_the_anchor_sanctions_a_removal_in_a_sibling() {
let old = [
in_unit("u", "u", package(vec![], vec![], vec![])),
in_unit(
"u",
"u.cluster",
package(
vec![frozen("Old", 3, door_opened("DoorState"))],
vec![],
vec![],
),
),
];
let new = [
in_unit(
"u",
"u",
package(vec![], vec![], vec![retired("cluster.Old", 3)]),
),
in_unit("u", "u.cluster", package(vec![], vec![], vec![])),
];
let report = diff_sets(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![change(
"u.cluster/Old",
Category::InterfaceRetired,
Verdict::Compatible,
Some("interface"),
Some("retired"),
)]
);
}
#[test]
fn a_retired_number_sanctions_the_removal_of_a_package_of_a_living_unit() {
let old = [
in_unit("u", "u", package(vec![], vec![], vec![])),
in_unit(
"u",
"u.cluster",
package(
vec![frozen("Old", 3, door_opened("DoorState"))],
vec![],
vec![],
),
),
];
let new = [in_unit(
"u",
"u",
package(vec![], vec![], vec![retired("cluster.Old", 3)]),
)];
let report = diff_sets(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![change(
"u.cluster/Old",
Category::InterfaceRetired,
Verdict::Compatible,
Some("interface"),
Some("retired"),
)]
);
}
#[test]
fn a_package_gone_from_a_living_unit_is_read_declaration_by_declaration() {
let speed = || frozen("Speed", 1, door_opened("DoorState"));
let mut cluster = package(
vec![speed(), frozen("Gone", 2, door_opened("DoorState"))],
vec![],
vec![],
);
cluster.decls.push(v2::Decl {
name: "Level".to_string(),
..Default::default()
});
let old = [
in_unit("u", "u", package(vec![], vec![], vec![])),
in_unit("u", "u.cluster", cluster),
];
let new = [in_unit("u", "u", package(vec![speed()], vec![], vec![]))];
let report = diff_sets(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
report.changes,
vec![
change(
"u.cluster/Level",
Category::DeclRemoved,
Verdict::Breaking,
Some("declaration"),
None,
),
change(
"u.cluster/Gone",
Category::DeclRemoved,
Verdict::Breaking,
Some("interface"),
None,
),
change(
"u/Speed",
Category::InterfaceRenamed,
Verdict::Compatible,
Some("cluster.Speed"),
Some("Speed"),
),
]
);
}
#[test]
fn a_snapshot_without_a_unit_is_its_own_unit() {
let foo = || frozen("Foo", 1, door_opened("DoorState"));
let old = [in_unit("", "p", package(vec![foo()], vec![], vec![]))];
let new = [in_unit("p", "p", package(vec![foo()], vec![], vec![]))];
let report = diff_sets(&old, &new);
assert_eq!(report.verdict, Verdict::Identical);
assert_eq!(report.changes, vec![]);
}
#[test]
fn a_sub_package_snapshot_without_a_unit_is_outside_the_new_unit() {
let speed = || frozen("Speed", 1, door_opened("DoorState"));
let old = [in_unit(
"",
"u.cluster",
package(vec![speed()], vec![], vec![]),
)];
let new = [in_unit(
"u",
"u.cluster",
package(vec![speed()], vec![], vec![]),
)];
let report = diff_sets(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
report
.changes
.iter()
.map(|change| (change.path.as_str(), change.category))
.collect::<Vec<_>>(),
vec![
("u.cluster/Speed", Category::DeclRemoved),
("u.cluster/Speed", Category::DeclAdded),
]
);
}
fn with_decl(name: &str, kind: v2::decl::Kind) -> v2::Package {
let mut package = package(vec![], vec![], vec![]);
package.decls = vec![v2::Decl {
name: name.to_string(),
kind: Some(kind),
..Default::default()
}];
package
}
fn numbered(members: &[(&str, i64)]) -> Vec<v2::EnumValue> {
members
.iter()
.map(|(name, value)| v2::EnumValue {
name: name.to_string(),
value: *value,
..Default::default()
})
.collect()
}
fn enum_def(members: &[(&str, i64)]) -> v2::decl::Kind {
v2::decl::Kind::EnumDef(v2::EnumDef {
values: numbered(members),
..Default::default()
})
}
fn enum_set_def(members: &[(&str, i64)]) -> v2::decl::Kind {
v2::decl::Kind::EnumSetDef(v2::EnumSetDef {
bits: numbered(members),
..Default::default()
})
}
#[test]
fn an_enum_pure_reorder_is_enum_reordered() {
let old = with_decl("Mode", enum_def(&[("OFF", 0), ("ON", 1)]));
let new = with_decl("Mode", enum_def(&[("ON", 1), ("OFF", 0)]));
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![Change {
path: "veh.cluster/Mode".to_string(),
category: Category::EnumReordered,
verdict: Verdict::Compatible,
before: None,
after: None,
}]
);
assert_eq!(
crate::heading(Category::EnumReordered),
Some("compatible on the wire, may change the catalog hash"),
);
}
#[test]
fn an_enum_set_pure_reorder_is_enum_reordered() {
let old = with_decl("Flags", enum_set_def(&[("A", 0), ("B", 1)]));
let new = with_decl("Flags", enum_set_def(&[("B", 1), ("A", 0)]));
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
report.changes,
vec![Change {
path: "veh.cluster/Flags".to_string(),
category: Category::EnumReordered,
verdict: Verdict::Compatible,
before: None,
after: None,
}]
);
}
#[test]
fn an_enum_renumber_is_member_reordered_with_the_value() {
let old = with_decl("Mode", enum_def(&[("OFF", 0), ("ON", 1)]));
let new = with_decl("Mode", enum_def(&[("OFF", 1), ("ON", 0)]));
let report = diff_packages(&old, &new);
let summary: Vec<_> = report
.changes
.iter()
.map(|change| {
(
change.path.as_str(),
change.category,
change.before.as_deref(),
change.after.as_deref(),
)
})
.collect();
assert_eq!(
summary,
vec![
(
"veh.cluster/Mode/OFF",
Category::MemberReordered,
Some("value 0"),
Some("value 1"),
),
(
"veh.cluster/Mode/ON",
Category::MemberReordered,
Some("value 1"),
Some("value 0"),
),
]
);
}
fn enum_with_reserved(members: &[(&str, i64)], retired: &[i64]) -> v2::decl::Kind {
v2::decl::Kind::EnumDef(v2::EnumDef {
values: numbered(members),
reserved: retired
.iter()
.map(|value| v2::Reserved {
value: Some(*value),
..Default::default()
})
.collect(),
open: false,
})
}
#[test]
fn an_enum_reserved_list_reorder_is_enum_reordered() {
let old = with_decl("Mode", enum_with_reserved(&[("OFF", 0)], &[3, 4]));
let new = with_decl("Mode", enum_with_reserved(&[("OFF", 0)], &[4, 3]));
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Compatible);
let summary: Vec<_> = report
.changes
.iter()
.map(|change| (change.path.as_str(), change.category))
.collect();
assert_eq!(summary, vec![("veh.cluster/Mode", Category::EnumReordered)]);
}
#[test]
fn an_enum_renumber_beside_a_reserved_list_change_keeps_constraint_changed() {
let old = with_decl("Mode", enum_with_reserved(&[("OFF", 0), ("ON", 1)], &[]));
let new = with_decl("Mode", enum_with_reserved(&[("OFF", 1), ("ON", 0)], &[5]));
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
let summary: Vec<_> = report
.changes
.iter()
.map(|change| (change.path.as_str(), change.category))
.collect();
assert_eq!(
summary,
vec![
("veh.cluster/Mode/OFF", Category::MemberReordered),
("veh.cluster/Mode/ON", Category::MemberReordered),
("veh.cluster/Mode", Category::ConstraintChanged),
]
);
}
#[test]
fn an_enum_renumber_beside_a_reserved_list_reorder_reports_only_the_values() {
let old = with_decl(
"Mode",
enum_with_reserved(&[("OFF", 0), ("ON", 1)], &[3, 4]),
);
let new = with_decl(
"Mode",
enum_with_reserved(&[("OFF", 1), ("ON", 0)], &[4, 3]),
);
let report = diff_packages(&old, &new);
assert_eq!(report.verdict, Verdict::Breaking);
let summary: Vec<_> = report
.changes
.iter()
.map(|change| (change.path.as_str(), change.category))
.collect();
assert_eq!(
summary,
vec![
("veh.cluster/Mode/OFF", Category::MemberReordered),
("veh.cluster/Mode/ON", Category::MemberReordered),
]
);
}
#[test]
fn an_enum_set_renumber_beside_another_change_keeps_constraint_changed() {
let old = with_decl("Flags", enum_set_def(&[("A", 0), ("B", 1)]));
let mut new = with_decl("Flags", enum_set_def(&[("A", 2), ("B", 1)]));
if let Some(v2::decl::Kind::EnumSetDef(def)) = &mut new.decls[0].kind {
def.backing_enum = Some("Mode".to_string());
}
let report = diff_packages(&old, &new);
let summary: Vec<_> = report
.changes
.iter()
.map(|change| (change.path.as_str(), change.category))
.collect();
assert_eq!(
summary,
vec![
("veh.cluster/Flags/A", Category::MemberReordered),
("veh.cluster/Flags", Category::ConstraintChanged),
]
);
}
#[test]
fn an_enum_set_renumber_alone_reports_only_the_bit() {
let old = with_decl("Flags", enum_set_def(&[("A", 0), ("B", 1)]));
let new = with_decl("Flags", enum_set_def(&[("B", 1), ("A", 2)]));
let report = diff_packages(&old, &new);
let summary: Vec<_> = report
.changes
.iter()
.map(|change| {
(
change.path.as_str(),
change.category,
change.before.as_deref(),
change.after.as_deref(),
)
})
.collect();
assert_eq!(
summary,
vec![(
"veh.cluster/Flags/A",
Category::MemberReordered,
Some("bit 0"),
Some("bit 2"),
)]
);
}
#[test]
fn the_snapshot_loader_is_re_exported() {
let load: fn(&std::path::Path) -> Result<v2::Package, crate::LoadError> = crate::load_ir_json;
let _ = load;
}
mod podbuf {
use ridl_ir::v2;
use crate::{Category, Verdict, diff_refs_with, render_text};
#[test]
fn remote_inline_growth_without_local_append_is_breaking() {
let local = package(
"app.api",
vec![struct_decl(
"Sample",
vec![
field("reading", 1, named("dep.types.Reading")),
field("sequence", 2, wide()),
],
)],
);
let old_remote = package(
"dep.types",
vec![struct_decl("Reading", vec![field("value", 1, wide())])],
);
let new_remote = package(
"dep.types",
vec![struct_decl(
"Reading",
vec![field("value", 1, wide()), field("extra", 2, wide())],
)],
);
let report = crate::diff_refs_with_contexts(
&[&local],
&[&local],
&[&old_remote],
&[&new_remote],
&["podbuf"],
)
.unwrap();
let change = report
.changes
.iter()
.find(|change| crate::category_word(change.category) == "podbuf_remote_inline_growth")
.expect("remote inline growth must be reported without a local append");
assert_eq!(change.path, "app.api/Sample/reading");
assert_eq!(change.before.as_deref(), Some("dep.types.Reading: 8"));
assert_eq!(change.after.as_deref(), Some("dep.types.Reading: 16"));
assert_eq!(change.verdict, Verdict::Breaking);
}
#[test]
fn partial_context_report_retains_known_verdicts_and_local_ownership() {
let unknown = package(
"unknown",
vec![struct_decl("S", vec![field("x", 1, named("absent.S"))])],
);
let known = remote_holder(named("dep.types.Reading"));
let local_dep = package(
"local.dep",
vec![struct_decl("S", vec![field("x", 1, wide())])],
);
let local_user = package(
"local.user",
vec![struct_decl("S", vec![field("x", 1, named("local.dep.S"))])],
);
let old_remote = remote_reading(false);
let new_remote = remote_reading(true);
let packages = [&unknown, &known, &local_dep, &local_user];
let (report, issues) = crate::diff_refs_with_contexts_partial(
&packages,
&packages,
&[&old_remote],
&[&new_remote],
&["podbuf"],
);
assert_eq!(
issues.len(),
2,
"only the unknown remote has missing evidence on each side"
);
assert!(issues.iter().all(|issue| issue.path == "unknown/S/x"));
let verdicts = crate::UnitVerdicts::from_refs(&report, &packages, &packages);
assert_eq!(verdicts.verdict("app.api"), Verdict::Breaking);
assert_eq!(verdicts.verdict("local.user"), Verdict::Identical);
assert_eq!(verdicts.verdict("local.dep"), Verdict::Identical);
assert!(
crate::diff_refs_with_contexts(
&packages,
&packages,
&[&old_remote],
&[&new_remote],
&["podbuf"]
)
.is_err()
);
}
fn remote_reading(extra: bool) -> v2::Package {
let mut members = vec![field("value", 1, wide())];
if extra {
members.push(field("extra", 2, wide()));
}
package("dep.types", vec![struct_decl("Reading", members)])
}
fn remote_holder(ty: v2::FieldType) -> v2::Package {
package(
"app.api",
vec![struct_decl(
"Sample",
vec![field("reading", 1, ty), field("sequence", 2, wide())],
)],
)
}
fn checked_remote(
local: &v2::Package,
old: &v2::Package,
new: &v2::Package,
encodings: &[&str],
) -> crate::DiffReport {
crate::diff_refs_with_contexts(&[local], &[local], &[old], &[new], encodings).unwrap()
}
fn remote_changes(report: &crate::DiffReport) -> Vec<&crate::Change> {
report
.changes
.iter()
.filter(|c| c.category == Category::PodbufRemoteInlineGrowth)
.collect()
}
#[test]
fn remote_dropped_or_retyped_holder_does_not_require_new_context() {
let old = remote_holder(named("dep.types.Reading"));
let remote = remote_reading(false);
for (new, path, category) in [
(
package(
"app.api",
vec![struct_decl("Sample", vec![field("sequence", 2, wide())])],
),
"app.api/Sample/reading",
Category::DeclRemoved,
),
(
remote_holder(wide()),
"app.api/Sample",
Category::ConstraintChanged,
),
] {
let (report, issues) = crate::diff_refs_with_contexts_partial(
&[&old],
&[&new],
&[&remote],
&[],
&["podbuf"],
);
assert!(issues.is_empty(), "{issues:?}");
assert_eq!(report.verdict, Verdict::Breaking);
assert!(remote_changes(&report).is_empty(), "{report:?}");
assert!(
report
.changes
.iter()
.any(|c| c.path == path && c.category == category),
"{report:?}"
);
}
}
fn assert_changed_holder_ignores_new_remote(new: v2::Package, remote: v2::Package) {
let old = remote_holder(named("dep.types.Reading"));
let old_remote = remote_reading(false);
let (report, issues) = crate::diff_refs_with_contexts_partial(
&[&old],
&[&new],
&[&old_remote],
&[&remote],
&["podbuf"],
);
assert!(issues.is_empty(), "{issues:?}");
assert!(remote_changes(&report).is_empty(), "{report:?}");
assert_eq!(report.verdict, Verdict::Breaking);
}
fn dropped_remote_holder() -> v2::Package {
package(
"app.api",
vec![struct_decl("Sample", vec![field("sequence", 2, wide())])],
)
}
#[test]
fn remote_dropped_holder_ignores_grown_new_remote() {
assert_changed_holder_ignores_new_remote(dropped_remote_holder(), remote_reading(true));
}
#[test]
fn remote_dropped_holder_ignores_new_remote_kind_change() {
let remote = package(
"dep.types",
vec![typl_decl("Reading", super::enum_def(&[("OFF", 0)]))],
);
assert_changed_holder_ignores_new_remote(dropped_remote_holder(), remote);
}
#[test]
fn remote_dropped_holder_ignores_unlayable_new_remote() {
let remote = package(
"dep.types",
vec![struct_decl(
"Reading",
vec![field("recursive", 1, named("Reading"))],
)],
);
assert_changed_holder_ignores_new_remote(dropped_remote_holder(), remote);
}
#[test]
fn remote_retyped_holder_ignores_grown_new_remote() {
assert_changed_holder_ignores_new_remote(remote_holder(wide()), remote_reading(true));
}
#[test]
fn remote_moved_out_of_workspace_requires_new_evidence() {
let local = remote_holder(named("dep.types.Reading"));
let remote = remote_reading(false);
let (_, issues) = crate::diff_refs_with_contexts_partial(
&[&local, &remote],
&[&local],
&[],
&[],
&["podbuf"],
);
assert_eq!(
issues,
vec![crate::ContextIssue {
path: "app.api/Sample/reading".into(),
declaration: "dep.types.Reading".into(),
side: crate::ContextSide::New,
reason: crate::ContextIssueReason::Missing,
}]
);
}
#[test]
fn remote_unresolved_payload_root_records_each_sides_issue() {
let mut local = package("app.api", vec![]);
local.interfaces = vec![super::interface(
"Api",
vec![fixed("reading", 1, named("dep.types.Reading"))],
)];
let remote = remote_reading(false);
for side in [crate::ContextSide::Old, crate::ContextSide::New] {
let context = [&remote];
let (old, new) = match side {
crate::ContextSide::Old => (&[][..], &context[..]),
crate::ContextSide::New => (&context[..], &[][..]),
};
let (_, issues) =
crate::diff_refs_with_contexts_partial(&[&local], &[&local], old, new, &["podbuf"]);
assert_eq!(
issues,
vec![crate::ContextIssue {
path: "app.api/Api/reading".into(),
declaration: "dep.types.Reading".into(),
side,
reason: crate::ContextIssueReason::Missing,
}]
);
}
}
fn remote_flags(count: u64, width: v2::IntWidth) -> v2::Package {
package(
"dep.types",
vec![typl_decl(
"Flags",
v2::decl::Kind::EnumSetDef(v2::EnumSetDef {
bits: (0..count)
.map(|value| v2::EnumValue {
name: format!("F{value}"),
value: value.try_into().unwrap(),
..Default::default()
})
.collect(),
width: width as i32,
open: true,
..Default::default()
}),
)],
)
}
#[test]
fn remote_enum_set_width_growth_is_reported() {
let local = remote_holder(named("dep.types.Flags"));
let report = checked_remote(
&local,
&remote_flags(8, v2::IntWidth::U8),
&remote_flags(9, v2::IntWidth::U16),
&["podbuf"],
);
let changes = remote_changes(&report);
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Sample/reading");
assert_eq!(changes[0].before.as_deref(), Some("dep.types.Flags: 1"));
assert_eq!(changes[0].after.as_deref(), Some("dep.types.Flags: 2"));
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn remote_enum_set_flatbuffers_width_is_reported_at_the_consumer_path() {
let local = remote_holder(named("dep.types.Flags"));
for (encodings, verdict) in [
(vec!["flatbuffers"], Verdict::Breaking),
(vec![], Verdict::Compatible),
] {
let report = checked_remote(
&local,
&remote_flags(8, v2::IntWidth::U8),
&remote_flags(9, v2::IntWidth::U16),
&encodings,
);
let changes: Vec<_> = report
.changes
.iter()
.filter(|c| c.category == Category::FlatbuffersWidthChange)
.collect();
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Sample/reading");
assert_eq!(changes[0].before.as_deref(), Some("dep.types.Flags: u8"));
assert_eq!(changes[0].after.as_deref(), Some("dep.types.Flags: u16"));
assert_eq!(changes[0].verdict, verdict);
}
}
#[test]
fn remote_enum_set_proto3_width_is_reported_only_at_bit_32() {
let local = remote_holder(named("dep.types.Flags"));
let report = checked_remote(
&local,
&remote_flags(32, v2::IntWidth::U32),
&remote_flags(33, v2::IntWidth::U64),
&["proto3"],
);
let changes: Vec<_> = report
.changes
.iter()
.filter(|c| c.category == Category::Proto3WidthChange)
.collect();
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Sample/reading");
assert_eq!(
changes[0].before.as_deref(),
Some("dep.types.Flags: uint32")
);
assert_eq!(changes[0].after.as_deref(), Some("dep.types.Flags: uint64"));
assert_eq!(changes[0].verdict, Verdict::Breaking);
let report = checked_remote(
&local,
&remote_flags(8, v2::IntWidth::U8),
&remote_flags(9, v2::IntWidth::U16),
&["proto3"],
);
assert!(
!report
.changes
.iter()
.any(|c| c.category == Category::Proto3WidthChange),
"{report:?}"
);
}
#[test]
fn remote_width_is_reported_at_producer_sized_positions() {
let local = remote_holder(array(named("dep.types.Flags"), 0, 4));
let report = checked_remote(
&local,
&remote_flags(8, v2::IntWidth::U8),
&remote_flags(9, v2::IntWidth::U16),
&["flatbuffers"],
);
assert_eq!(
report
.changes
.iter()
.filter(|c| c.category == Category::FlatbuffersWidthChange)
.count(),
1,
"{report:?}"
);
assert!(remote_changes(&report).is_empty(), "{report:?}");
}
#[test]
fn remote_width_reaches_a_stream_parameter() {
let mut local = package("app.api", vec![]);
local.interfaces = vec![super::interface(
"Api",
vec![command(
"send",
1,
vec![param("items", named_flags_stream())],
)],
)];
assert_stream_widths(&local, "app.api/Api/send");
}
#[test]
fn remote_width_reaches_a_streamed_reply() {
let mut local = package("app.api", vec![]);
local.interfaces = vec![super::interface(
"Api",
vec![query("receive", 1, vec![], named_flags_stream())],
)];
assert_stream_widths(&local, "app.api/Api/receive");
}
fn named_flags_stream() -> v2::FieldType {
v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
element: Some(v2::stream_type::Element::Named("dep.types.Flags".into())),
})),
}
}
#[track_caller]
fn assert_stream_widths(local: &v2::Package, path: &str) {
for encoding in ["proto3", "flatbuffers", "podbuf"] {
let report = checked_remote(
local,
&remote_flags(32, v2::IntWidth::U32),
&remote_flags(33, v2::IntWidth::U64),
&[encoding],
);
assert!(remote_changes(&report).is_empty(), "{report:?}");
for (category, before, after, selected) in [
(Category::Proto3WidthChange, "uint32", "uint64", "proto3"),
(
Category::FlatbuffersWidthChange,
"u32",
"u64",
"flatbuffers",
),
] {
let changes: Vec<_> = report
.changes
.iter()
.filter(|c| c.category == category)
.collect();
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, path);
assert_eq!(
changes[0].before,
Some(format!("dep.types.Flags: {before}"))
);
assert_eq!(changes[0].after, Some(format!("dep.types.Flags: {after}")));
assert_eq!(
changes[0].verdict,
if encoding == selected {
Verdict::Breaking
} else {
Verdict::Compatible
}
);
}
}
}
#[test]
fn remote_width_reached_through_a_remote_struct_keeps_the_consumer_path() {
let local = remote_holder(named("dep.types.Reading"));
let mut old = remote_flags(32, v2::IntWidth::U32);
let mut new = remote_flags(33, v2::IntWidth::U64);
for package in [&mut old, &mut new] {
package.decls.push(struct_decl(
"Reading",
vec![
field("flags", 1, named("Flags")),
field("other_flags", 2, named("Flags")),
],
));
}
let report = checked_remote(&local, &old, &new, &["flatbuffers", "proto3"]);
for (category, before, after) in [
(Category::FlatbuffersWidthChange, "u32", "u64"),
(Category::Proto3WidthChange, "uint32", "uint64"),
] {
let changes: Vec<_> = report
.changes
.iter()
.filter(|c| c.category == category)
.collect();
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Sample/reading");
assert_eq!(
changes[0].before,
Some(format!("dep.types.Flags: {before}"))
);
assert_eq!(changes[0].after, Some(format!("dep.types.Flags: {after}")));
assert_eq!(changes[0].verdict, Verdict::Breaking);
}
}
#[test]
fn remote_width_ignores_a_retyped_holder() {
let old = remote_holder(named("dep.types.Flags"));
let new = remote_holder(wide());
let report = crate::diff_refs_with_contexts(
&[&old],
&[&new],
&[&remote_flags(8, v2::IntWidth::U8)],
&[&remote_flags(9, v2::IntWidth::U16)],
&["flatbuffers"],
)
.unwrap();
assert!(
!report
.changes
.iter()
.any(|c| c.category == Category::FlatbuffersWidthChange),
"{report:?}"
);
}
#[test]
fn remote_width_without_new_context_draws_no_line() {
let local = remote_holder(named("dep.types.Flags"));
let old = remote_flags(8, v2::IntWidth::U8);
let (report, issues) = crate::diff_refs_with_contexts_partial(
&[&local],
&[&local],
&[&old],
&[],
&["flatbuffers"],
);
assert!(
!report
.changes
.iter()
.any(|c| c.category == Category::FlatbuffersWidthChange),
"{report:?}"
);
assert_eq!(
issues,
vec![crate::ContextIssue {
path: "app.api/Sample/reading".into(),
declaration: "dep.types.Flags".into(),
side: crate::ContextSide::New,
reason: crate::ContextIssueReason::Missing,
}]
);
}
#[test]
fn remote_new_holder_requires_its_own_new_context() {
let old = package("app.api", vec![]);
let new = remote_holder(named("dep.types.Reading"));
let result = crate::diff_refs_with_contexts(&[&old], &[&new], &[], &[], &["podbuf"]);
assert!(result.is_err(), "{result:?}");
assert_eq!(
result.unwrap_err().issues,
vec![crate::ContextIssue {
path: "app.api/Sample/reading".into(),
declaration: "dep.types.Reading".into(),
side: crate::ContextSide::New,
reason: crate::ContextIssueReason::Missing,
}]
);
}
#[test]
fn remote_moved_into_workspace_still_reports_old_inline_growth() {
let local = remote_holder(named("dep.types.Reading"));
let old_remote = remote_reading(false);
let new_local = remote_reading(true);
let report = crate::diff_refs_with_contexts(
&[&local],
&[&local, &new_local],
&[&old_remote],
&[],
&["podbuf"],
)
.unwrap();
let changes = remote_changes(&report);
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Sample/reading");
assert_eq!(changes[0].before.as_deref(), Some("dep.types.Reading: 8"));
assert_eq!(changes[0].after.as_deref(), Some("dep.types.Reading: 16"));
assert_eq!(changes[0].verdict, Verdict::Breaking);
}
#[test]
fn remote_moved_into_workspace_still_requires_old_evidence() {
let local = remote_holder(named("dep.types.Reading"));
let new_local = remote_reading(false);
let result =
crate::diff_refs_with_contexts(&[&local], &[&local, &new_local], &[], &[], &["podbuf"]);
assert!(result.is_err(), "{result:?}");
let error = result.unwrap_err();
assert_eq!(
error.to_string(),
"app.api/Sample/reading: old context is missing dep.types.Reading"
);
assert_eq!(
error.issues,
vec![crate::ContextIssue {
path: "app.api/Sample/reading".into(),
declaration: "dep.types.Reading".into(),
side: crate::ContextSide::Old,
reason: crate::ContextIssueReason::Missing,
}]
);
}
fn remote_modes(count: u64) -> v2::Package {
package(
"dep.types",
vec![typl_decl(
"Mode",
v2::decl::Kind::EnumDef(v2::EnumDef {
values: (0..count)
.map(|value| v2::EnumValue {
name: format!("V{value}"),
value: value.try_into().unwrap(),
..Default::default()
})
.collect(),
open: true,
..Default::default()
}),
)],
)
}
#[test]
fn remote_enum_width_growth_is_reported() {
let local = remote_holder(named("dep.types.Mode"));
let report = checked_remote(&local, &remote_modes(256), &remote_modes(257), &["podbuf"]);
let changes = remote_changes(&report);
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Sample/reading");
assert_eq!(changes[0].before.as_deref(), Some("dep.types.Mode: 1"));
assert_eq!(changes[0].after.as_deref(), Some("dep.types.Mode: 2"));
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn remote_enum_draws_no_flatbuffers_width_line() {
let report = checked_remote(
&remote_holder(named("dep.types.Mode")),
&remote_modes(256),
&remote_modes(257),
&["flatbuffers", "proto3"],
);
assert!(
!report.changes.iter().any(|c| matches!(
c.category,
Category::FlatbuffersWidthChange | Category::Proto3WidthChange
)),
"{report:?}"
);
}
#[test]
fn remote_scalar_width_growth_is_reported() {
let local = remote_holder(named("dep.types.Level"));
let old = package("dep.types", vec![int_type("Level", "0", "255")]);
let mut new = package("dep.types", vec![int_type("Level", "0", "65535")]);
if let Some(v2::decl::Kind::TypeDef(def)) = &mut new.decls[0].kind {
def.width = Some(v2::type_def::Width::IntWidth(v2::IntWidth::U16 as i32));
}
let report = checked_remote(&local, &old, &new, &["podbuf"]);
let changes = remote_changes(&report);
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Sample/reading");
assert_eq!(changes[0].before.as_deref(), Some("dep.types.Level: 1"));
assert_eq!(changes[0].after.as_deref(), Some("dep.types.Level: 2"));
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn remote_union_tag_growth_that_fits_padding_is_not_head_growth() {
let union = |count| {
package(
"dep.types",
vec![
int_type("Level", "0", "255"),
typl_decl(
"Choice",
v2::decl::Kind::UnionDef(v2::UnionDef {
arms: (1..=count)
.map(|ordinal| v2::UnionArm {
name: format!("arm{ordinal}"),
ordinal,
type_ref: "Level".into(),
..Default::default()
})
.collect(),
..Default::default()
}),
),
],
)
};
let report = checked_remote(
&remote_holder(named("dep.types.Choice")),
&union(255),
&union(256),
&["podbuf", "flatbuffers", "proto3"],
);
assert_eq!(report.verdict, Verdict::Identical, "{report:?}");
assert!(remote_changes(&report).is_empty(), "{report:?}");
}
fn assert_remote_layout_error(side: crate::ContextSide) {
let local = remote_holder(named("dep.types.Reading"));
let valid = remote_reading(false);
let invalid = package(
"dep.types",
vec![struct_decl(
"Reading",
vec![field("recursive", 1, named("Reading"))],
)],
);
let (old, new) = match side {
crate::ContextSide::Old => (&invalid, &valid),
crate::ContextSide::New => (&valid, &invalid),
};
let result =
crate::diff_refs_with_contexts(&[&local], &[&local], &[old], &[new], &["podbuf"]);
assert!(result.is_err(), "{result:?}");
let error = result.unwrap_err();
assert_eq!(
error.to_string(),
format!("app.api/Sample/reading: {side} context cannot lay out dep.types.Reading")
);
assert_eq!(
error.issues,
vec![crate::ContextIssue {
path: "app.api/Sample/reading".into(),
declaration: "dep.types.Reading".into(),
side,
reason: crate::ContextIssueReason::Unlayable,
}]
);
}
#[test]
fn remote_old_layout_error_requires_old_context() {
assert_remote_layout_error(crate::ContextSide::Old);
}
#[test]
fn remote_new_layout_error_requires_new_context() {
assert_remote_layout_error(crate::ContextSide::New);
}
#[test]
fn remote_struct_replaced_by_an_enum_requires_new_context() {
let local = remote_holder(named("dep.types.Reading"));
let old = remote_reading(false);
let new = package(
"dep.types",
vec![typl_decl("Reading", super::enum_def(&[("OFF", 0)]))],
);
let result =
crate::diff_refs_with_contexts(&[&local], &[&local], &[&old], &[&new], &["podbuf"]);
assert!(result.is_err(), "{result:?}");
let error = result.unwrap_err();
assert_eq!(
error.to_string(),
"app.api/Sample/reading: new context changes dep.types.Reading from a struct to another declaration kind"
);
assert_eq!(
error.issues,
vec![crate::ContextIssue {
path: "app.api/Sample/reading".into(),
declaration: "dep.types.Reading".into(),
side: crate::ContextSide::New,
reason: crate::ContextIssueReason::KindChanged,
}]
);
}
#[test]
fn remote_shrinkage_is_not_growth() {
let local = remote_holder(named("dep.types.Reading"));
let report = checked_remote(
&local,
&remote_reading(true),
&remote_reading(false),
&["podbuf"],
);
assert!(remote_changes(&report).is_empty(), "{report:?}");
assert_eq!(report.verdict, Verdict::Identical);
}
#[test]
fn remote_growth_stops_at_the_nearest_local_holder() {
let mut local = remote_holder(named("dep.types.Reading"));
local.decls.push(struct_decl(
"Outer",
vec![field("sample", 1, named("Sample"))],
));
let report = checked_remote(
&local,
&remote_reading(false),
&remote_reading(true),
&["podbuf"],
);
let changes = remote_changes(&report);
let paths: Vec<_> = changes.iter().map(|c| c.path.as_str()).collect();
assert_eq!(paths, ["app.api/Sample/reading"]);
}
#[test]
fn remote_growth_respects_producer_size_boundaries_and_inline_heads() {
let old = remote_reading(false);
let new = remote_reading(true);
for (ty, expected) in [
(named("dep.types.Reading"), 1),
(array(named("dep.types.Reading"), 2, 2), 1),
(array(named("dep.types.Reading"), 0, 2), 0),
(map(wide(), named("dep.types.Reading")), 0),
(
array(
tuple(vec![
("reading", named("dep.types.Reading")),
("sequence", wide()),
]),
0,
2,
),
1,
),
(
map(wide(), tuple(vec![("reading", named("dep.types.Reading"))])),
1,
),
] {
let report = checked_remote(&remote_holder(ty), &old, &new, &["podbuf"]);
let changes = remote_changes(&report);
assert_eq!(changes.len(), expected, "{report:?}");
for change in changes {
assert_eq!(change.path, "app.api/Sample/reading");
assert_eq!(change.before.as_deref(), Some("dep.types.Reading: 8"));
assert_eq!(change.after.as_deref(), Some("dep.types.Reading: 16"));
}
}
}
#[test]
fn remote_growth_in_fixed_array_inside_bounded_array_is_breaking() {
let local = remote_holder(array(array(named("dep.types.Reading"), 2, 2), 0, 4));
let report = checked_remote(
&local,
&remote_reading(false),
&remote_reading(true),
&["podbuf"],
);
let changes = remote_changes(&report);
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Sample/reading");
assert_eq!(changes[0].verdict, Verdict::Breaking);
}
#[test]
fn remote_growth_in_fixed_array_inside_map_value_is_breaking() {
let local = remote_holder(map(wide(), array(named("dep.types.Reading"), 2, 2)));
let report = checked_remote(
&local,
&remote_reading(false),
&remote_reading(true),
&["podbuf"],
);
let changes = remote_changes(&report);
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Sample/reading");
assert_eq!(changes[0].verdict, Verdict::Breaking);
}
#[test]
fn remote_growth_in_exact_array_root_has_no_inline_head() {
let mut local = package("app.api", vec![]);
local.interfaces = vec![super::interface(
"Api",
vec![fixed("reading", 1, array(named("dep.types.Reading"), 2, 2))],
)];
let report = checked_remote(
&local,
&remote_reading(false),
&remote_reading(true),
&["podbuf"],
);
assert!(remote_changes(&report).is_empty(), "{report:?}");
}
#[test]
fn remote_roots_can_grow_but_their_nested_inline_members_cannot() {
let old = remote_reading(false);
let new = remote_reading(true);
let root = |ty| {
let mut p = package("app.api", vec![]);
p.interfaces = vec![super::interface("Api", vec![fixed("reading", 1, ty)])];
p
};
assert!(
remote_changes(&checked_remote(
&root(named("dep.types.Reading")),
&old,
&new,
&["podbuf"]
))
.is_empty()
);
let mut old_outer = old.clone();
old_outer.decls.push(struct_decl(
"Outer",
vec![field("inner", 1, named("Reading"))],
));
let mut new_outer = new.clone();
new_outer.decls.push(struct_decl(
"Outer",
vec![field("inner", 1, named("Reading"))],
));
for ty in [
named("dep.types.Outer"),
array(named("dep.types.Outer"), 0, 4),
] {
let local = root(ty);
let report = checked_remote(&local, &old_outer, &new_outer, &["podbuf"]);
let changes = remote_changes(&report);
assert_eq!(changes.len(), 1, "{report:?}");
assert_eq!(changes[0].path, "app.api/Api/reading");
assert_eq!(changes[0].before.as_deref(), Some("dep.types.Reading: 8"));
}
}
#[test]
fn remote_equal_size_and_unrelated_changes_are_not_growth() {
let mut old = remote_reading(false);
old.decls[0] = struct_decl("Reading", vec![field("small", 1, named("Small"))]);
old.decls.push(int_type("Small", "0", "255"));
let mut new = old.clone();
new.decls[0] = struct_decl(
"Reading",
vec![
field("small", 1, named("Small")),
field("more", 2, named("Small")),
],
);
let local = remote_holder(named("dep.types.Reading"));
assert!(remote_changes(&checked_remote(&local, &old, &new, &["podbuf"])).is_empty());
new.decls
.push(struct_decl("Unrelated", vec![field("value", 1, wide())]));
assert!(remote_changes(&checked_remote(&local, &old, &new, &["podbuf"])).is_empty());
}
#[test]
fn remote_growth_without_podbuf_is_compatible_under_the_podbuf_heading() {
let report = checked_remote(
&remote_holder(named("dep.types.Reading")),
&remote_reading(false),
&remote_reading(true),
&[],
);
assert_eq!(report.verdict, Verdict::Compatible);
assert!(render_text(&report).contains("breaking under podbuf:"));
assert_eq!(
crate::category_from_word("podbuf_remote_inline_growth"),
Some(Category::PodbufRemoteInlineGrowth)
);
assert!(crate::explain(Category::PodbufRemoteInlineGrowth).contains("consumer"));
}
#[test]
fn missing_remote_evidence_never_borrows_the_other_pin() {
let local = remote_holder(named("dep.types.Reading"));
let remote = remote_reading(false);
let empty = package("dep.types", vec![]);
for (old, new, side) in [
(&[][..], &[&remote][..], crate::ContextSide::Old),
(&[&remote][..], &[][..], crate::ContextSide::New),
(&[&remote][..], &[&empty][..], crate::ContextSide::New),
] {
let error = crate::diff_refs_with_contexts(&[&local], &[&local], old, new, &["podbuf"])
.unwrap_err();
assert_eq!(
error.issues,
vec![crate::ContextIssue {
path: "app.api/Sample/reading".into(),
declaration: "dep.types.Reading".into(),
side,
reason: crate::ContextIssueReason::Missing,
}]
);
assert!(error.to_string().contains("dep.types.Reading"));
assert!(crate::diff_refs_with_contexts(&[&local], &[&local], old, new, &[]).is_ok());
}
}
#[test]
fn remote_growth_counts_only_old_holders_and_deduplicates_consumers() {
let old_local = package("app.api", vec![]);
let new_local = remote_holder(named("dep.types.Reading"));
let old = remote_reading(false);
let new = remote_reading(true);
let report = crate::diff_refs_with_contexts(
&[&old_local],
&[&new_local],
&[&old],
&[&new],
&["podbuf"],
)
.unwrap();
assert!(remote_changes(&report).is_empty());
let mut local = remote_holder(tuple(vec![
("first", named("dep.types.Reading")),
("second", named("dep.types.Reading")),
]));
local.decls.push(struct_decl(
"Other",
vec![field("value", 1, named("dep.types.Reading"))],
));
let report = checked_remote(&local, &old, &new, &["podbuf"]);
let paths: Vec<_> = remote_changes(&report)
.iter()
.map(|c| c.path.as_str())
.collect();
assert_eq!(paths, ["app.api/Other/value", "app.api/Sample/reading"]);
}
#[test]
fn remote_growth_in_induced_request_and_reply_tuples_is_reported() {
let mut local = package("app.api", vec![]);
local.interfaces = vec![super::interface(
"Api",
vec![
command(
"send",
1,
vec![
param("reading", named("dep.types.Reading")),
param("sequence", wide()),
],
),
query(
"get",
2,
vec![],
tuple(vec![
("reading", named("dep.types.Reading")),
("sequence", wide()),
]),
),
],
)];
let report = checked_remote(
&local,
&remote_reading(false),
&remote_reading(true),
&["podbuf"],
);
let paths: Vec<_> = remote_changes(&report)
.iter()
.map(|c| c.path.as_str())
.collect();
assert_eq!(paths, ["app.api/Api/get", "app.api/Api/send"]);
}
#[test]
fn remote_union_arms_allow_direct_growth_but_examine_nested_heads() {
let union = |name| {
typl_decl(
"Choice",
v2::decl::Kind::UnionDef(v2::UnionDef {
arms: vec![v2::UnionArm {
name: "value".into(),
ordinal: 1,
type_ref: name,
..Default::default()
}],
..Default::default()
}),
)
};
let mut old = remote_reading(false);
let mut new = remote_reading(true);
old.decls.push(union("Reading".into()));
new.decls.push(union("Reading".into()));
let local = remote_holder(named("dep.types.Choice"));
assert!(remote_changes(&checked_remote(&local, &old, &new, &["podbuf"])).is_empty());
old.decls.push(struct_decl(
"Outer",
vec![field("items", 1, array(named("Reading"), 2, 2))],
));
new.decls.push(struct_decl(
"Outer",
vec![field("items", 1, array(named("Reading"), 2, 2))],
));
old.decls.retain(|d| d.name != "Choice");
new.decls.retain(|d| d.name != "Choice");
old.decls.push(union("Outer".into()));
new.decls.push(union("Outer".into()));
let report = checked_remote(&local, &old, &new, &["podbuf"]);
assert_eq!(remote_changes(&report).len(), 1, "{report:?}");
}
#[test]
fn remote_nested_growth_keeps_consumer_ownership() {
let mut old = remote_reading(false);
let mut new = remote_reading(true);
old.decls.push(struct_decl(
"Outer",
vec![field("inner", 1, named("Reading"))],
));
new.decls.push(struct_decl(
"Outer",
vec![field("inner", 1, named("Reading"))],
));
let report = checked_remote(
&remote_holder(named("dep.types.Outer")),
&old,
&new,
&["podbuf"],
);
let changes = remote_changes(&report);
assert_eq!(changes.len(), 2);
assert!(changes.iter().all(|c| c.path == "app.api/Sample/reading"));
}
#[test]
fn remote_signal_and_event_roots_examine_inline_members() {
let mut old = remote_reading(false);
let mut new = remote_reading(true);
old.decls.push(struct_decl(
"Outer",
vec![field("inner", 1, named("Reading"))],
));
new.decls.push(struct_decl(
"Outer",
vec![field("inner", 1, named("Reading"))],
));
let mut local = package("app.api", vec![]);
local.interfaces = vec![super::interface(
"Api",
vec![
super::signal("signal", 1, "dep.types.Outer"),
super::event("event", 2, "dep.types.Outer"),
],
)];
let report = checked_remote(&local, &old, &new, &["podbuf"]);
assert_eq!(remote_changes(&report).len(), 2, "{report:?}");
}
#[test]
fn remote_members_inside_local_union_arms_are_consumer_owned() {
let mut old = remote_reading(false);
let mut new = remote_reading(true);
old.decls.push(struct_decl(
"Outer",
vec![field("inner", 1, named("Reading"))],
));
new.decls.push(struct_decl(
"Outer",
vec![field("inner", 1, named("Reading"))],
));
let local = package(
"app.api",
vec![typl_decl(
"Choice",
v2::decl::Kind::UnionDef(v2::UnionDef {
arms: vec![v2::UnionArm {
name: "value".into(),
ordinal: 1,
type_ref: "dep.types.Outer".into(),
..Default::default()
}],
..Default::default()
}),
)],
);
let report = checked_remote(&local, &old, &new, &["podbuf"]);
assert_eq!(remote_changes(&report).len(), 1, "{report:?}");
assert_eq!(remote_changes(&report)[0].path, "app.api/Choice/value");
}
#[test]
fn local_inline_append_uses_each_sources_remote_head() {
let local = |append: Option<v2::FieldType>| {
let mut members = vec![
field("reading", 1, named("dep.types.Reading")),
field("small", 2, named("Small")),
];
if let Some(ty) = append {
members.push(field("extra", 3, ty));
}
package(
"app.api",
vec![
int_type("Small", "0", "255"),
struct_decl("Held", members),
struct_decl("Holder", vec![field("held", 1, named("Held"))]),
],
)
};
let old_local = local(None);
for (old_remote, new_remote, append, checked_break, shared_break) in [
(
remote_reading(false),
remote_reading(true),
named("Small"),
true,
false,
),
(
remote_reading(true),
remote_reading(false),
wide(),
false,
true,
),
] {
let new_local = local(Some(append));
let checked = crate::diff_refs_with_contexts(
&[&old_local],
&[&new_local],
&[&old_remote],
&[&new_remote],
&["podbuf"],
)
.unwrap();
let shared = diff_refs_with(&[&old_local], &[&new_local], &[&new_remote], &["podbuf"]);
assert_eq!(
!inline_append_paths(&checked).is_empty(),
checked_break,
"{checked:?}"
);
assert_eq!(
!inline_append_paths(&shared).is_empty(),
shared_break,
"{shared:?}"
);
}
}
#[test]
fn removed_nested_remote_declaration_is_missing_new_evidence() {
let mut old = remote_reading(false);
old.decls.push(struct_decl(
"Outer",
vec![field("inner", 1, named("Reading"))],
));
let new = package(
"dep.types",
vec![struct_decl("Outer", vec![field("replacement", 1, wide())])],
);
let local = remote_holder(named("dep.types.Outer"));
let error =
crate::diff_refs_with_contexts(&[&local], &[&local], &[&old], &[&new], &["podbuf"])
.unwrap_err();
assert_eq!(
error.issues,
vec![crate::ContextIssue {
path: "app.api/Sample/reading".into(),
declaration: "dep.types.Reading".into(),
side: crate::ContextSide::New,
reason: crate::ContextIssueReason::Missing,
}]
);
}
#[test]
fn missing_remote_issues_have_stable_consumer_order() {
let local = package(
"app.api",
vec![struct_decl(
"Sample",
vec![
field("second", 1, named("dep.types.Reading")),
field(
"first",
2,
tuple(vec![
("one", named("dep.types.Reading")),
("two", named("dep.types.Reading")),
]),
),
],
)],
);
let error = crate::diff_refs_with_contexts(&[&local], &[&local], &[], &[], &["podbuf"])
.unwrap_err();
let order: Vec<_> = error
.issues
.iter()
.map(|i| (i.path.as_str(), i.side))
.collect();
assert_eq!(
order,
[
("app.api/Sample/first", crate::ContextSide::Old),
("app.api/Sample/first", crate::ContextSide::New),
("app.api/Sample/second", crate::ContextSide::Old),
("app.api/Sample/second", crate::ContextSide::New),
]
);
}
fn typl_decl(name: &str, kind: v2::decl::Kind) -> v2::Decl {
v2::Decl {
limit: None,
name: name.to_string(),
visibility: v2::Visibility::Public as i32,
is_error: false,
doc: String::new(),
labels: Vec::new(),
deprecated: None,
ordinal: 0,
kind: Some(kind),
links: Vec::new(),
see: Vec::new(),
since: Vec::new(),
}
}
fn int_type(name: &str, min: &str, max: &str) -> v2::Decl {
typl_decl(
name,
v2::decl::Kind::TypeDef(v2::TypeDef {
backing: Some(v2::Backing {
kind: Some(v2::backing::Kind::Primitive(
v2::PrimitiveType::Integer as i32,
)),
}),
constraint: Some(v2::Constraint {
min: Some(min.to_string()),
max: Some(max.to_string()),
..Default::default()
}),
declared_init: None,
init: None,
width: Some(v2::type_def::Width::IntWidth(v2::IntWidth::U8 as i32)),
}),
)
}
fn string_type(name: &str) -> v2::Decl {
typl_decl(
name,
v2::decl::Kind::TypeDef(v2::TypeDef {
backing: Some(v2::Backing {
kind: Some(v2::backing::Kind::Primitive(
v2::PrimitiveType::String as i32,
)),
}),
constraint: Some(v2::Constraint {
len_min: Some(0),
len_max: Some(16),
..Default::default()
}),
declared_init: None,
init: None,
width: None,
}),
)
}
fn named(reference: &str) -> v2::FieldType {
v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Named(reference.to_string())),
}
}
fn array(element: v2::FieldType, min: u64, max: u64) -> v2::FieldType {
v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
element: Some(Box::new(element)),
min,
max,
}))),
}
}
fn map(key: v2::FieldType, value: v2::FieldType) -> v2::FieldType {
v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Map(Box::new(v2::MapType {
key: Some(Box::new(key)),
value: Some(Box::new(value)),
min: 0,
max: 8,
}))),
}
}
fn tuple(fields: Vec<(&str, v2::FieldType)>) -> v2::FieldType {
v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
fields: fields
.into_iter()
.map(|(name, ty)| v2::TupleField {
name: name.to_string(),
r#type: Some(ty),
})
.collect(),
})),
}
}
fn field(name: &str, ordinal: u32, ty: v2::FieldType) -> v2::StructMember {
v2::StructMember {
member: Some(v2::struct_member::Member::Field(Box::new(v2::Field {
name: name.to_string(),
ordinal,
r#type: Some(ty),
declared_init: None,
init: None,
doc: String::new(),
labels: Vec::new(),
deprecated: None,
links: Vec::new(),
see: Vec::new(),
since: Vec::new(),
}))),
}
}
fn reserved_field(ordinal: u32) -> v2::StructMember {
v2::StructMember {
member: Some(v2::struct_member::Member::Reserved(v2::Reserved {
ordinal,
name: None,
value: None,
})),
}
}
fn struct_decl(name: &str, members: Vec<v2::StructMember>) -> v2::Decl {
typl_decl(
name,
v2::decl::Kind::StructDef(v2::StructDef {
members,
fixed_layout: false,
}),
)
}
fn package(name: &str, decls: Vec<v2::Decl>) -> v2::Package {
v2::Package {
name: name.to_string(),
decls,
interfaces: Vec::new(),
services: Vec::new(),
retired: Vec::new(),
unit: String::new(),
}
}
fn reading_v1() -> v2::Decl {
struct_decl("SensorReading", vec![field("value", 1, named("Level"))])
}
fn wide() -> v2::FieldType {
v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::InlineScalar(Box::new(v2::TypeDef {
backing: Some(v2::Backing {
kind: Some(v2::backing::Kind::Primitive(
v2::PrimitiveType::Integer as i32,
)),
}),
constraint: Some(v2::Constraint {
min: Some("0".to_string()),
max: Some("9223372036854775807".to_string()),
..Default::default()
}),
declared_init: None,
init: None,
width: Some(v2::type_def::Width::IntWidth(v2::IntWidth::U64 as i32)),
}))),
}
}
fn reading_v2() -> v2::Decl {
struct_decl(
"SensorReading",
vec![
field("value", 1, named("Level")),
field("quality", 2, wide()),
],
)
}
fn reading_v2_in_pads() -> v2::Decl {
struct_decl(
"SensorReading",
vec![
field("value", 1, named("Level")),
field("quality", 2, named("Level")),
],
)
}
fn inline_package(reading: v2::Decl) -> v2::Package {
package(
"veh.common",
vec![
int_type("Level", "0", "100"),
reading,
struct_decl(
"SensorBounds",
vec![
field("range", 1, named("SensorReading")),
field("limit", 2, named("Level")),
field(
"pair",
3,
tuple(vec![
("low", named("Level")),
("high", named("SensorReading")),
]),
),
field("window", 4, array(named("SensorReading"), 2, 2)),
],
),
],
)
}
fn tail_package(reading: v2::Decl) -> v2::Package {
package(
"veh.common",
vec![
int_type("Level", "0", "100"),
string_type("Label"),
reading,
struct_decl(
"Log",
vec![
field("entries", 1, array(named("SensorReading"), 0, 8)),
field("by_label", 2, map(named("Label"), named("SensorReading"))),
],
),
],
)
}
fn diff(old: &v2::Package, new: &v2::Package, encodings: &[&str]) -> crate::DiffReport {
diff_refs_with(&[old], &[new], &[], encodings)
}
#[test]
fn appending_to_a_struct_nested_inline_is_a_podbuf_break_naming_the_uses() {
let report = diff(
&inline_package(reading_v1()),
&inline_package(reading_v2()),
&["podbuf"],
);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.expect("the append is reported as a podbuf inline append");
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(change.path, "veh.common/SensorReading/quality");
assert_eq!(change.before, None);
assert_eq!(
change.after.as_deref(),
Some(
"nested inline in veh.common/SensorBounds/pair, \
veh.common/SensorBounds/range, veh.common/SensorBounds/window"
)
);
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn an_append_that_fits_in_the_pad_bytes_of_an_inline_struct_is_not_a_podbuf_break() {
let report = diff(
&inline_package(reading_v1()),
&inline_package(reading_v2_in_pads()),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
let appended = report
.changes
.iter()
.find(|change| change.path == "veh.common/SensorReading/quality")
.expect("the append is reported");
assert_eq!(appended.category, Category::DeclAdded);
assert!(
report
.changes
.iter()
.all(|change| change.category != Category::PodbufInlineAppend),
"{:?}",
report.changes
);
}
fn level_package(name: &str) -> v2::Package {
package(name, vec![int_type("Level", "0", "100")])
}
fn held(members: Vec<v2::StructMember>) -> v2::Package {
package(
"veh.common",
vec![
int_type("Level", "0", "100"),
struct_decl("SensorReading", members),
struct_decl(
"SensorBounds",
vec![field("range", 1, named("SensorReading"))],
),
],
)
}
#[track_caller]
fn inline_append_paths(report: &crate::DiffReport) -> Vec<&str> {
report
.changes
.iter()
.filter(|change| change.category == Category::PodbufInlineAppend)
.map(|change| change.path.as_str())
.collect()
}
#[test]
fn a_multi_field_append_draws_a_line_for_the_fields_past_the_old_head_only() {
let new = struct_decl(
"SensorReading",
vec![
field("value", 1, named("Level")),
field("flag", 2, named("Level")),
field("quality", 3, wide()),
],
);
let report = diff(
&inline_package(reading_v1()),
&inline_package(new),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
}
fn optional(mut ty: v2::FieldType) -> v2::FieldType {
ty.optional = true;
ty
}
fn appended(name: &str, ty: v2::FieldType) -> crate::DiffReport {
let with_label = |reading: v2::Decl| {
let mut package = inline_package(reading);
package.decls.push(string_type("Label"));
package
};
let new = struct_decl(
"SensorReading",
vec![field("value", 1, named("Level")), field(name, 2, ty)],
);
diff(&with_label(reading_v1()), &with_label(new), &["podbuf"])
}
#[track_caller]
fn assert_one_line(report: &crate::DiffReport, field: &str) {
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(report),
[format!("veh.common/SensorReading/{field}")],
"{:?}",
report.changes
);
}
#[test]
fn an_appended_inline_array_that_straddles_the_old_head_end_is_a_break() {
let report = appended("block", optional(array(named("Level"), 8, 8)));
assert_one_line(&report, "block");
}
#[test]
fn a_field_that_ends_exactly_at_the_old_head_size_fits() {
let report = appended("block", optional(array(named("Level"), 6, 6)));
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
inline_append_paths(&report).is_empty(),
"{:?}",
report.changes
);
}
#[test]
fn a_field_that_ends_one_byte_past_the_old_head_size_does_not_fit() {
let report = appended("block", optional(array(named("Level"), 7, 7)));
assert_one_line(&report, "block");
}
#[test]
fn an_appended_descriptor_that_straddles_the_old_head_end_is_a_break() {
let report = appended("label", optional(named("Label")));
assert_one_line(&report, "label");
}
#[test]
fn a_package_in_both_old_and_context_names_each_use_once() {
let old = inline_package(reading_v1());
let new = inline_package(reading_v2());
let report = diff_refs_with(&[&old], &[&new], &[&old], &["podbuf"]);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.expect("the append is reported");
assert_eq!(
change.after.as_deref(),
Some(
"nested inline in veh.common/SensorBounds/pair, \
veh.common/SensorBounds/range, veh.common/SensorBounds/window"
)
);
}
#[test]
fn a_package_in_both_new_and_context_is_read_from_the_new_side() {
let old = inline_package(reading_v1());
let new = inline_package(reading_v2());
let report = diff_refs_with(&[&old], &[&new], &[&old], &["podbuf"]);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
}
#[test]
fn a_pad_fit_append_whose_type_is_in_another_package_is_not_a_break() {
let other = level_package("veh.other");
let old = [held(vec![field("value", 1, named("Level"))]), other.clone()];
let new = [
held(vec![
field("value", 1, named("Level")),
field("quality", 2, named("veh.other.Level")),
]),
other,
];
let old: Vec<&v2::Package> = old.iter().collect();
let new: Vec<&v2::Package> = new.iter().collect();
let report = diff_refs_with(&old, &new, &[], &["podbuf"]);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
inline_append_paths(&report).is_empty(),
"{:?}",
report.changes
);
}
#[test]
fn a_pad_fit_append_whose_type_is_in_the_context_is_not_a_break() {
let context = level_package("ridl.std");
let old = held(vec![field("value", 1, named("ridl.std.Level"))]);
let new = held(vec![
field("value", 1, named("ridl.std.Level")),
field("quality", 2, named("ridl.std.Level")),
]);
let report = diff_refs_with(&[&old], &[&new], &[&context], &["podbuf"]);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
inline_append_paths(&report).is_empty(),
"{:?}",
report.changes
);
}
#[test]
fn a_growing_append_to_a_struct_with_a_context_typed_field_is_a_break() {
let context = level_package("ridl.std");
let old = held(vec![field("value", 1, named("ridl.std.Level"))]);
let new = held(vec![
field("value", 1, named("ridl.std.Level")),
field("quality", 2, wide()),
]);
let report = diff_refs_with(&[&old], &[&new], &[&context], &["podbuf"]);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
}
fn held_unresolved(members: Vec<v2::StructMember>, range: v2::FieldType) -> v2::Package {
let mut fields = vec![field("value", 1, named("veh.remote.Level"))];
fields.extend(members);
package(
"veh.common",
vec![
int_type("Level", "0", "100"),
struct_decl("SensorReading", fields),
struct_decl("SensorBounds", vec![field("range", 1, range)]),
],
)
}
#[test]
fn a_growing_append_to_a_struct_whose_old_head_cannot_be_laid_out_is_a_break() {
let old = held_unresolved(vec![], named("SensorReading"));
let new = held_unresolved(vec![field("quality", 2, wide())], named("SensorReading"));
let report = diff(&old, &new, &["podbuf"]);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.unwrap();
assert_eq!(
change.after.as_deref(),
Some("nested inline in veh.common/SensorBounds/range")
);
}
#[test]
fn a_one_byte_append_to_a_struct_whose_old_head_cannot_be_laid_out_is_a_break() {
let old = held_unresolved(vec![], named("SensorReading"));
let new = held_unresolved(
vec![field("quality", 2, named("Level"))],
named("SensorReading"),
);
let report = diff(&old, &new, &["podbuf"]);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
}
#[test]
fn a_growing_append_to_a_struct_held_in_a_fixed_array_whose_old_head_cannot_be_laid_out_is_a_break()
{
let window = array(named("SensorReading"), 2, 2);
let old = held_unresolved(vec![], window.clone());
let new = held_unresolved(vec![field("quality", 2, wide())], window);
let report = diff(&old, &new, &["podbuf"]);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
}
#[test]
fn a_growing_append_to_a_struct_held_in_a_bounded_array_whose_old_head_cannot_be_laid_out_is_not_a_break()
{
let entries = array(named("SensorReading"), 0, 8);
let old = held_unresolved(vec![], entries.clone());
let new = held_unresolved(vec![field("quality", 2, wide())], entries);
let report = diff(&old, &new, &["podbuf"]);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
inline_append_paths(&report).is_empty(),
"{:?}",
report.changes
);
}
#[test]
fn a_growing_append_to_a_struct_held_in_a_tuple_whose_old_head_cannot_be_laid_out_is_a_break() {
let pair = tuple(vec![
("low", named("Level")),
("high", named("SensorReading")),
]);
let old = held_unresolved(vec![], pair.clone());
let new = held_unresolved(vec![field("quality", 2, wide())], pair);
let report = diff(&old, &new, &["podbuf"]);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
}
#[test]
fn a_holder_naming_another_packages_struct_of_the_same_name_is_not_a_use() {
let other = package(
"veh.other",
vec![struct_decl(
"SensorReading",
vec![field("value", 1, named("veh.remote.Level"))],
)],
);
let old = [
held_unresolved(vec![], named("veh.other.SensorReading")),
other.clone(),
];
let new = [
held_unresolved(
vec![field("quality", 2, wide())],
named("veh.other.SensorReading"),
),
other,
];
let old: Vec<&v2::Package> = old.iter().collect();
let new: Vec<&v2::Package> = new.iter().collect();
let report = diff_refs_with(&old, &new, &[], &["podbuf"]);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
inline_append_paths(&report).is_empty(),
"{:?}",
report.changes
);
}
#[test]
fn a_growing_append_to_a_struct_held_in_a_tuple_of_a_bounded_array_whose_old_head_cannot_be_laid_out_is_a_break()
{
let entries = array(
tuple(vec![
("reading", named("SensorReading")),
("at", named("Level")),
]),
0,
4,
);
let old = held_unresolved(vec![], entries.clone());
let new = held_unresolved(vec![field("quality", 2, wide())], entries);
let report = diff(&old, &new, &["podbuf"]);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
}
#[test]
fn a_growing_append_to_a_struct_streamed_whose_old_head_cannot_be_laid_out_is_not_a_break() {
let streamed = v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
element: Some(v2::stream_type::Element::Named("SensorReading".to_string())),
})),
};
let old = held_unresolved(vec![], streamed.clone());
let new = held_unresolved(vec![field("quality", 2, wide())], streamed);
let report = diff(&old, &new, &["podbuf"]);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
inline_append_paths(&report).is_empty(),
"{:?}",
report.changes
);
}
#[test]
fn a_growing_append_to_a_struct_held_in_a_bounded_array_with_a_minimum_whose_old_head_cannot_be_laid_out_is_not_a_break()
{
let entries = array(named("SensorReading"), 2, 8);
let old = held_unresolved(vec![], entries.clone());
let new = held_unresolved(vec![field("quality", 2, wide())], entries);
let report = diff(&old, &new, &["podbuf"]);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
inline_append_paths(&report).is_empty(),
"{:?}",
report.changes
);
}
#[test]
fn a_pad_fit_append_whose_old_head_cannot_be_laid_out_but_new_head_can_is_a_break() {
let old = held_unresolved(vec![], named("SensorReading"));
let new = [
held_unresolved(
vec![field("quality", 2, named("Level"))],
named("SensorReading"),
),
level_package("veh.remote"),
];
let new: Vec<&v2::Package> = new.iter().collect();
let report = diff_refs_with(&[&old], &new, &[], &["podbuf"]);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
}
fn interface_unresolved(
members: Vec<v2::StructMember>,
interactions: Vec<v2::Decl>,
) -> v2::Package {
let mut package = held_unresolved(members, named("Level"));
package.interfaces = vec![super::interface("Ctl", interactions)];
package
}
#[test]
fn a_call_whose_argument_struct_holds_a_struct_whose_old_head_cannot_be_laid_out_is_a_break() {
let two = || {
vec![command(
"set",
1,
vec![
param("a", named("SensorReading")),
param("b", named("Level")),
],
)]
};
let report = diff(
&interface_unresolved(vec![], two()),
&interface_unresolved(vec![field("quality", 2, wide())], two()),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.unwrap_or_else(|| panic!("the use is found: {:?}", report.changes));
assert_eq!(
change.after.as_deref(),
Some("nested inline in veh.common/Ctl/set")
);
let one = || vec![command("set", 1, vec![param("a", named("SensorReading"))])];
let report = diff(
&interface_unresolved(vec![], one()),
&interface_unresolved(vec![field("quality", 2, wide())], one()),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
}
#[test]
fn an_exact_array_root_of_a_struct_whose_old_head_cannot_be_laid_out_is_not_a_use_but_of_a_tuple_is()
{
let of_struct = || vec![fixed("window", 1, array(named("SensorReading"), 2, 2))];
let report = diff(
&interface_unresolved(vec![], of_struct()),
&interface_unresolved(vec![field("quality", 2, wide())], of_struct()),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
let of_tuple = || {
vec![fixed(
"window",
1,
array(
tuple(vec![
("reading", named("SensorReading")),
("at", named("Level")),
]),
2,
2,
),
)]
};
let report = diff(
&interface_unresolved(vec![], of_tuple()),
&interface_unresolved(vec![field("quality", 2, wide())], of_tuple()),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
assert_eq!(
inline_append_paths(&report),
["veh.common/SensorReading/quality"],
"{:?}",
report.changes
);
}
#[test]
fn the_shape_walk_finds_the_holders_the_law_finds() {
use ridl_ir::projection::flatbuffers::Packages;
use ridl_ir::projection::podbuf::{
Inlined, field_form, root_shape_holds_inline, shape_holds_inline,
};
let home = package(
"veh.common",
vec![
int_type("Level", "0", "100"),
string_type("Label"),
reading_v1(),
],
);
let packages = Packages {
package: &home,
others: &[],
};
let target = Inlined::Declaration {
package: "veh.common".to_string(),
name: "SensorReading".to_string(),
};
let reading = || named("SensorReading");
let optional = || v2::FieldType {
optional: true,
..named("SensorReading")
};
let pair = || tuple(vec![("reading", reading()), ("at", named("Level"))]);
let table: Vec<(&str, v2::FieldType, bool, bool)> = vec![
("direct", reading(), true, false),
("optional", optional(), true, false),
("exact array", array(reading(), 2, 2), true, false),
("bounded array 0..8", array(reading(), 0, 8), false, false),
("bounded array 2..8", array(reading(), 2, 8), false, false),
("bounded array of a tuple", array(pair(), 0, 4), true, true),
(
"map with the struct in the value tuple",
map(named("Label"), pair()),
true,
true,
),
(
"map with the struct as the value",
map(named("Label"), reading()),
false,
false,
),
("tuple", pair(), true, true),
(
"nested tuple",
tuple(vec![("inner", pair()), ("at", named("Level"))]),
true,
true,
),
("exact array of a tuple", array(pair(), 2, 2), true, true),
(
"exact array of an exact array",
array(array(reading(), 2, 2), 2, 2),
true,
false,
),
("another type", named("Level"), false, false),
("a scalar", wide(), false, false),
];
for (label, ty, in_field, at_root) in table {
let form = field_form(packages, &ty).unwrap_or_else(|err| panic!("{label}: {err:?}"));
let law = crate::walk::holds_inline(&form, &target, true);
let shape = shape_holds_inline(packages, &ty, "veh.common", "SensorReading");
assert_eq!(law, in_field, "{label}: the law reads a field");
assert_eq!(shape, in_field, "{label}: the shape reads a field");
let law = crate::walk::root_holds_inline(&form, &target);
let shape = root_shape_holds_inline(packages, &ty, "veh.common", "SensorReading");
assert_eq!(law, at_root, "{label}: the law reads a root");
assert_eq!(shape, at_root, "{label}: the shape reads a root");
}
}
#[test]
fn a_pad_fit_append_whose_type_is_new_on_the_new_side_is_not_a_break() {
let old = held(vec![field("value", 1, named("Level"))]);
let mut new = held(vec![
field("value", 1, named("Level")),
field("quality", 2, named("Fresh")),
]);
new.decls.push(int_type("Fresh", "0", "10"));
let report = diff(&old, &new, &["podbuf"]);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
inline_append_paths(&report).is_empty(),
"{:?}",
report.changes
);
}
#[test]
fn an_append_whose_form_the_law_refuses_is_read_as_grown() {
let refused = struct_decl(
"SensorReading",
vec![
field("value", 1, named("Level")),
field(
"note",
2,
v2::FieldType {
optional: true,
kind: Some(v2::field_type::Kind::Primitive(
v2::PrimitiveType::String as i32,
)),
},
),
],
);
let report = diff(
&inline_package(reading_v1()),
&inline_package(refused),
&["podbuf"],
);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.unwrap_or_else(|| panic!("the append is reported: {:?}", report.changes));
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(change.path, "veh.common/SensorReading/note");
}
#[test]
fn a_holder_new_on_the_new_side_does_not_make_the_append_a_break() {
let old = package(
"veh.common",
vec![int_type("Level", "0", "100"), reading_v1()],
);
let report = diff(&old, &inline_package(reading_v2()), &["podbuf"]);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
report
.changes
.iter()
.all(|change| change.category != Category::PodbufInlineAppend),
"{:?}",
report.changes
);
}
#[test]
fn the_detail_names_the_holders_of_the_old_side_only() {
let mut new = inline_package(reading_v2());
new.decls.push(struct_decl(
"Frame",
vec![field("reading", 1, named("SensorReading"))],
));
let report = diff(&inline_package(reading_v1()), &new, &["podbuf"]);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.expect("the append is reported");
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(
change.after.as_deref(),
Some(
"nested inline in veh.common/SensorBounds/pair, \
veh.common/SensorBounds/range, veh.common/SensorBounds/window"
)
);
}
#[test]
fn the_same_append_is_compatible_without_podbuf_and_listed_under_its_heading() {
let report = diff(
&inline_package(reading_v1()),
&inline_package(reading_v2()),
&[],
);
assert_eq!(report.verdict, Verdict::Compatible);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.expect("the append is still reported, as compatible");
assert_eq!(change.verdict, Verdict::Compatible);
let text = render_text(&report);
assert!(
text.contains(
"breaking under podbuf:\n [compatible] podbuf_inline_append \
veh.common/SensorReading/quality: (absent) -> nested inline in"
),
"{text}"
);
}
#[test]
fn an_encoding_without_podbuf_rules_leaves_the_verdict_compatible() {
let report = diff(
&inline_package(reading_v1()),
&inline_package(reading_v2()),
&["flatbuffers", "proto3"],
);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn an_inline_use_in_another_package_is_named() {
let old = [
package(
"veh.common",
vec![int_type("Level", "0", "100"), reading_v1()],
),
package(
"veh.other",
vec![struct_decl(
"Frame",
vec![field("reading", 1, named("veh.common.SensorReading"))],
)],
),
];
let new = [
package(
"veh.common",
vec![int_type("Level", "0", "100"), reading_v2()],
),
old[1].clone(),
];
let old: Vec<&v2::Package> = old.iter().collect();
let new: Vec<&v2::Package> = new.iter().collect();
let report = diff_refs_with(&old, &new, &[], &["podbuf"]);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.expect("the cross-package use is found");
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(
change.after.as_deref(),
Some("nested inline in veh.other/Frame/reading")
);
}
#[test]
fn an_appended_field_to_an_array_element_type_is_compatible_under_podbuf() {
let report = diff(
&tail_package(reading_v1()),
&tail_package(reading_v2()),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
report
.changes
.iter()
.all(|change| change.category != Category::PodbufInlineAppend),
"{:?}",
report.changes
);
}
#[test]
fn an_appended_field_whose_type_excludes_zero_is_breaking_under_every_encoding() {
let with_nonzero = |reading: v2::Decl| {
let mut package = inline_package(reading);
package.decls.push(int_type("Positive", "1", "100"));
package
};
let new = struct_decl(
"SensorReading",
vec![
field("value", 1, named("Level")),
field("nonzero", 2, named("Positive")),
],
);
for encodings in [&[][..], &["podbuf"][..]] {
let report = diff(
&with_nonzero(reading_v1()),
&with_nonzero(new.clone()),
encodings,
);
assert_eq!(report.verdict, Verdict::Breaking, "{encodings:?}");
let appended = report
.changes
.iter()
.find(|change| change.path == "veh.common/SensorReading/nonzero")
.expect("the append is reported");
assert_eq!(appended.category, Category::DeclAdded);
assert_eq!(appended.verdict, Verdict::Breaking);
assert!(
report
.changes
.iter()
.all(|change| change.category != Category::PodbufInlineAppend),
"{encodings:?}: {:?}",
report.changes
);
}
}
#[test]
fn a_retired_field_is_breaking_under_every_encoding() {
let retired = struct_decl(
"SensorReading",
vec![field("value", 1, named("Level")), reserved_field(2)],
);
for encodings in [&[][..], &["podbuf"][..]] {
let report = diff(
&inline_package(reading_v2()),
&inline_package(retired.clone()),
encodings,
);
assert_eq!(report.verdict, Verdict::Breaking, "{encodings:?}");
let removed = report
.changes
.iter()
.find(|change| change.path == "veh.common/SensorReading/quality")
.expect("the removal is reported");
assert_eq!(removed.category, Category::DeclRemoved);
assert_eq!(removed.verdict, Verdict::Breaking);
assert_eq!(
report
.changes
.iter()
.filter(|change| change.path == "veh.common/SensorReading/quality")
.count(),
1,
"{encodings:?}: {:?}",
report.changes
);
}
}
#[test]
fn a_struct_inside_a_tuple_element_of_a_bounded_array_is_an_inline_use() {
let with_log = |reading: v2::Decl| {
package(
"veh.common",
vec![
int_type("Level", "0", "100"),
reading,
struct_decl(
"Log",
vec![field(
"entries",
1,
array(
tuple(vec![
("at", named("Level")),
("reading", named("SensorReading")),
]),
0,
8,
),
)],
),
],
)
};
let report = diff(
&with_log(reading_v1()),
&with_log(reading_v2()),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Breaking, "{:?}", report.changes);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.expect("the use inside the tuple is found");
assert_eq!(
change.after.as_deref(),
Some("nested inline in veh.common/Log/entries")
);
}
#[test]
fn a_use_through_another_inline_declaration_is_named_once() {
let with_frame = |reading: v2::Decl| {
let mut package = inline_package(reading);
package.decls.push(struct_decl(
"Frame",
vec![field("bounds", 1, named("SensorBounds"))],
));
package
};
let report = diff(
&with_frame(reading_v1()),
&with_frame(reading_v2()),
&["podbuf"],
);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.expect("the append is reported");
assert_eq!(
change.after.as_deref(),
Some(
"nested inline in veh.common/SensorBounds/pair, \
veh.common/SensorBounds/range, veh.common/SensorBounds/window"
)
);
}
#[test]
fn an_arm_appended_to_an_inline_union_is_not_a_podbuf_break() {
let arm = |name: &str, ordinal: u32| v2::UnionArm {
name: name.to_string(),
ordinal,
type_ref: "Level".to_string(),
doc: String::new(),
links: Vec::new(),
see: Vec::new(),
since: Vec::new(),
};
let with_union = |arms: Vec<v2::UnionArm>| {
package(
"veh.common",
vec![
int_type("Level", "0", "100"),
typl_decl(
"Choice",
v2::decl::Kind::UnionDef(v2::UnionDef {
arms,
reserved: Vec::new(),
is_result: false,
}),
),
struct_decl("Holder", vec![field("choice", 1, named("Choice"))]),
],
)
};
let report = diff(
&with_union(vec![arm("low", 1)]),
&with_union(vec![arm("low", 1), arm("high", 2)]),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
assert!(
report
.changes
.iter()
.all(|change| change.category != Category::PodbufInlineAppend),
"{:?}",
report.changes
);
}
#[test]
fn a_struct_inside_a_tuple_map_value_is_an_inline_use() {
let with_index = |reading: v2::Decl| {
package(
"veh.common",
vec![
int_type("Level", "0", "100"),
string_type("Label"),
reading,
struct_decl(
"Index",
vec![field(
"by_label",
1,
map(
named("Label"),
tuple(vec![
("at", named("Level")),
("reading", named("SensorReading")),
]),
),
)],
),
],
)
};
let report = diff(
&with_index(reading_v1()),
&with_index(reading_v2()),
&["podbuf"],
);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.expect("the use inside the map value is found");
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(
change.after.as_deref(),
Some("nested inline in veh.common/Index/by_label")
);
}
#[test]
fn a_struct_in_an_exact_array_element_of_a_bounded_array_is_an_inline_use() {
let with_log = |reading: v2::Decl| {
package(
"veh.common",
vec![
int_type("Level", "0", "100"),
reading,
struct_decl(
"Log",
vec![field(
"windows",
1,
array(array(named("SensorReading"), 2, 2), 0, 8),
)],
),
],
)
};
let report = diff(
&with_log(reading_v1()),
&with_log(reading_v2()),
&["podbuf"],
);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.expect("the use inside the inline array is found");
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(
change.after.as_deref(),
Some("nested inline in veh.common/Log/windows")
);
}
fn param(name: &str, ty: v2::FieldType) -> v2::Param {
v2::Param {
name: name.to_string(),
r#type: Some(ty),
doc: String::new(),
links: Vec::new(),
see: Vec::new(),
since: Vec::new(),
}
}
fn command(name: &str, ordinal: u32, params: Vec<v2::Param>) -> v2::Decl {
super::interaction(
name,
ordinal,
v2::decl::Kind::CommandDef(v2::CommandDef {
params,
contracts: Vec::new(),
timing: None,
family: v2::Family::Dispatch as i32,
shape: v2::Shape::None as i32,
}),
)
}
fn query(name: &str, ordinal: u32, params: Vec<v2::Param>, reply: v2::FieldType) -> v2::Decl {
super::interaction(
name,
ordinal,
v2::decl::Kind::QueryDef(v2::QueryDef {
params,
return_type: Some(v2::ReturnType {
kind: Some(v2::return_type::Kind::Value(reply)),
}),
contracts: Vec::new(),
timing: None,
family: v2::Family::Dispatch as i32,
}),
)
}
fn fixed(name: &str, ordinal: u32, payload: v2::FieldType) -> v2::Decl {
super::interaction(
name,
ordinal,
v2::decl::Kind::FixedDef(v2::FixedDef {
payload: Some(payload),
}),
)
}
fn interface_package(reading: v2::Decl, interactions: Vec<v2::Decl>) -> v2::Package {
let mut package = package("veh.common", vec![int_type("Level", "0", "100"), reading]);
package.interfaces = vec![super::interface("Ctl", interactions)];
package
}
#[track_caller]
fn assert_interaction_use(interactions: impl Fn() -> Vec<v2::Decl>, expected: &str) {
let report = diff(
&interface_package(reading_v1(), interactions()),
&interface_package(reading_v2(), interactions()),
&["podbuf"],
);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.unwrap_or_else(|| panic!("the use is found: {:?}", report.changes));
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(change.after.as_deref(), Some(expected));
}
#[track_caller]
fn assert_no_interaction_use(interactions: impl Fn() -> Vec<v2::Decl>) {
let report = diff(
&interface_package(reading_v1(), interactions()),
&interface_package(reading_v2(), interactions()),
&["podbuf"],
);
assert_eq!(report.verdict, Verdict::Compatible, "{:?}", report.changes);
}
#[test]
fn a_two_parameter_call_holds_the_struct_in_its_argument_struct() {
assert_interaction_use(
|| {
vec![command(
"set",
1,
vec![
param("a", named("SensorReading")),
param("b", named("Level")),
],
)]
},
"nested inline in veh.common/Ctl/set",
);
}
#[test]
fn a_named_parameter_beside_a_stream_parameter_is_not_an_inline_use() {
assert_no_interaction_use(|| {
vec![command(
"put",
1,
vec![
param("info", named("SensorReading")),
byte_stream_param("data"),
],
)]
});
}
#[test]
fn a_named_parameter_beside_a_stream_parameter_is_a_root_that_is_not_entered() {
let holder_package = |reading: v2::Decl| {
let mut package = interface_package(
reading,
vec![command(
"put",
1,
vec![
param("info", named("SensorBounds")),
byte_stream_param("data"),
],
)],
);
package.decls.push(struct_decl(
"SensorBounds",
vec![field("range", 1, named("SensorReading"))],
));
package
};
let report = diff(
&holder_package(reading_v1()),
&holder_package(reading_v2()),
&["podbuf"],
);
let change = report
.changes
.iter()
.find(|change| change.category == Category::PodbufInlineAppend)
.unwrap_or_else(|| panic!("the use is found: {:?}", report.changes));
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(
change.after.as_deref(),
Some("nested inline in veh.common/SensorBounds/range")
);
}
fn byte_stream_param(name: &str) -> v2::Param {
param(
name,
v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
element: Some(v2::stream_type::Element::Primitive(
v2::PrimitiveType::Bytes as i32,
)),
})),
},
)
}
#[test]
fn a_stream_parameter_beside_two_named_parameters_keeps_the_argument_struct() {
assert_interaction_use(
|| {
vec![command(
"set",
1,
vec![
param(
"data",
v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
element: Some(v2::stream_type::Element::Primitive(
v2::PrimitiveType::Bytes as i32,
)),
})),
},
),
param("a", named("SensorReading")),
param("b", named("Level")),
],
)]
},
"nested inline in veh.common/Ctl/set",
);
}
#[test]
fn a_tuple_parameter_holds_the_struct_inline() {
assert_interaction_use(
|| {
vec![command(
"set",
1,
vec![param(
"p",
tuple(vec![
("reading", named("SensorReading")),
("at", named("Level")),
]),
)],
)]
},
"nested inline in veh.common/Ctl/set",
);
}
#[test]
fn an_exact_array_root_has_no_head_and_is_not_a_use() {
assert_no_interaction_use(|| {
vec![
fixed("window", 1, array(named("SensorReading"), 2, 2)),
query(
"read",
2,
vec![param("at", named("Level"))],
array(named("SensorReading"), 2, 2),
),
]
});
}
#[test]
fn a_tuple_inside_an_exact_array_fixed_payload_holds_the_struct_inline() {
assert_interaction_use(
|| {
vec![fixed(
"window",
1,
array(
tuple(vec![
("reading", named("SensorReading")),
("at", named("Level")),
]),
2,
2,
),
)]
},
"nested inline in veh.common/Ctl/window",
);
}
#[test]
fn a_tuple_reply_holds_the_struct_inline() {
assert_interaction_use(
|| {
vec![query(
"read",
1,
vec![param("at", named("Level"))],
tuple(vec![
("reading", named("SensorReading")),
("at", named("Level")),
]),
)]
},
"nested inline in veh.common/Ctl/read",
);
}
#[test]
fn a_single_named_parameter_and_a_named_reply_are_roots_not_uses() {
assert_no_interaction_use(|| {
vec![
command("set", 1, vec![param("reading", named("SensorReading"))]),
query(
"read",
2,
vec![param("at", named("Level"))],
named("SensorReading"),
),
fixed("last", 3, named("SensorReading")),
]
});
}
#[test]
fn diff_refs_in_judges_under_no_encoding() {
let old = inline_package(reading_v1());
let new = inline_package(reading_v2());
assert_eq!(
crate::diff_refs_in(&[&old], &[&new], &[]),
diff_refs_with(&[&old], &[&new], &[], &[])
);
}
}
mod podbuf_width {
use ridl_ir::v2;
use crate::{Category, Verdict, diff_refs_with, render_text};
fn enum_package(count: i64) -> v2::Package {
let values = (0..count)
.map(|value| v2::EnumValue {
name: format!("V{value}"),
value,
..Default::default()
})
.collect();
with_decl(
"Mode",
v2::decl::Kind::EnumDef(v2::EnumDef {
values,
open: true,
..Default::default()
}),
)
}
fn union_package(count: u32) -> v2::Package {
let arms = (1..=count)
.map(|ordinal| v2::UnionArm {
name: format!("a{ordinal}"),
ordinal,
type_ref: "Level".to_string(),
..Default::default()
})
.collect();
let mut package = with_decl(
"Outcome",
v2::decl::Kind::UnionDef(v2::UnionDef {
arms,
..Default::default()
}),
);
package.decls.push(v2::Decl {
name: "Level".to_string(),
kind: Some(v2::decl::Kind::TypeDef(v2::TypeDef {
backing: Some(v2::Backing {
kind: Some(v2::backing::Kind::Primitive(
v2::PrimitiveType::Integer as i32,
)),
}),
constraint: Some(v2::Constraint {
min: Some("0".to_string()),
max: Some("100".to_string()),
..Default::default()
}),
declared_init: None,
init: None,
width: Some(v2::type_def::Width::IntWidth(v2::IntWidth::U8 as i32)),
})),
..Default::default()
});
package
}
fn with_decl(name: &str, kind: v2::decl::Kind) -> v2::Package {
v2::Package {
name: "veh.cluster".to_string(),
decls: vec![v2::Decl {
name: name.to_string(),
kind: Some(kind),
..Default::default()
}],
..Default::default()
}
}
fn width_change(report: &crate::DiffReport) -> Option<&crate::Change> {
report
.changes
.iter()
.find(|change| change.category == Category::PodbufWidthChange)
}
#[test]
fn an_appended_enum_value_that_widens_the_integer_is_a_podbuf_break() {
let (old, new) = (enum_package(256), enum_package(257));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
let change = width_change(&report).expect("the width change is reported");
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(change.path, "veh.cluster/Mode");
assert_eq!(change.before.as_deref(), Some("u8"));
assert_eq!(change.after.as_deref(), Some("u16"));
assert_eq!(report.verdict, Verdict::Breaking);
let report = diff_refs_with(&[&old], &[&new], &[], &[]);
assert_eq!(report.verdict, Verdict::Compatible);
assert_eq!(
width_change(&report).map(|change| change.verdict),
Some(Verdict::Compatible)
);
assert!(
render_text(&report).contains(
"breaking under podbuf:\n [compatible] podbuf_width_change veh.cluster/Mode: u8 -> u16"
),
"{}",
render_text(&report)
);
}
#[test]
fn an_appended_enum_value_that_keeps_the_width_draws_no_width_line() {
let (old, new) = (enum_package(255), enum_package(256));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(width_change(&report), None);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn an_appended_union_arm_past_ordinal_255_widens_the_tag() {
let (old, new) = (union_package(255), union_package(256));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
let change = width_change(&report).expect("the width change is reported");
assert_eq!(change.verdict, Verdict::Breaking);
assert_eq!(change.path, "veh.cluster/Outcome");
assert_eq!(change.before.as_deref(), Some("u8"));
assert_eq!(change.after.as_deref(), Some("u16"));
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
diff_refs_with(&[&old], &[&new], &[], &[]).verdict,
Verdict::Compatible
);
let (old, new) = (union_package(254), union_package(255));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(width_change(&report), None);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn several_appended_values_draw_one_line() {
let (old, new) = (enum_package(255), enum_package(258));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(
report
.changes
.iter()
.filter(|change| change.category == Category::PodbufWidthChange)
.count(),
1
);
}
fn strict_append(report: &crate::DiffReport) -> Vec<&crate::Change> {
report
.changes
.iter()
.filter(|change| change.category == Category::StrictEnumAppend)
.collect()
}
fn strict(mut package: v2::Package) -> v2::Package {
for decl in &mut package.decls {
match &mut decl.kind {
Some(v2::decl::Kind::EnumDef(def)) => def.open = false,
Some(v2::decl::Kind::EnumSetDef(def)) => def.open = false,
_ => {}
}
}
package
}
#[test]
fn an_appended_enum_value_is_a_strict_enum_break_for_every_encoding() {
let (old, new) = (strict(enum_package(3)), strict(enum_package(4)));
for encodings in [&[][..], &["podbuf"], &["flatbuffers", "proto3"]] {
let report = diff_refs_with(&[&old], &[&new], &[], encodings);
let changes = strict_append(&report);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].verdict, Verdict::Breaking);
assert_eq!(changes[0].path, "veh.cluster/Mode");
assert_eq!(changes[0].after.as_deref(), Some("V3"));
assert_eq!(report.verdict, Verdict::Breaking);
assert!(
render_text(&report).contains("[breaking] strict_enum_append veh.cluster/Mode"),
"{}",
render_text(&report)
);
assert!(!render_text(&report).contains("breaking under podbuf"));
}
}
#[test]
fn a_strict_append_that_widens_the_integer_draws_no_width_line() {
let (old, new) = (strict(enum_package(256)), strict(enum_package(257)));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(width_change(&report), None);
assert_eq!(strict_append(&report).len(), 1);
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn a_value_inserted_at_a_free_number_draws_no_strict_enum_line() {
let package = |numbers: &[i64]| {
let values = numbers
.iter()
.map(|&value| v2::EnumValue {
name: format!("V{value}"),
value,
..Default::default()
})
.collect();
with_decl(
"Mode",
v2::decl::Kind::EnumDef(v2::EnumDef {
values,
..Default::default()
}),
)
};
let (old, new) = (package(&[0, 2]), package(&[0, 1, 2]));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(strict_append(&report), Vec::<&crate::Change>::new());
}
#[test]
fn an_appended_union_arm_draws_no_strict_enum_line() {
let (old, new) = (union_package(2), union_package(3));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(strict_append(&report), Vec::<&crate::Change>::new());
}
fn enum_set_with(bits: &[i64], width: v2::IntWidth) -> v2::Package {
let bits = bits
.iter()
.map(|&bit| v2::EnumValue {
name: format!("F{bit}"),
value: bit,
..Default::default()
})
.collect();
with_decl(
"Flags",
v2::decl::Kind::EnumSetDef(v2::EnumSetDef {
bits,
width: width as i32,
open: true,
..Default::default()
}),
)
}
#[test]
fn an_appended_enum_set_flag_that_widens_the_integer_is_a_podbuf_width_change() {
let old = enum_set_with(&[0, 7], v2::IntWidth::U8);
let new = enum_set_with(&[0, 7, 8], v2::IntWidth::U16);
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(
width_change(&report).map(|change| (
change.path.as_str(),
change.verdict,
change.before.as_deref(),
change.after.as_deref()
)),
Some((
"veh.cluster/Flags",
Verdict::Breaking,
Some("u8"),
Some("u16")
)),
"{}",
render_text(&report)
);
assert_eq!(strict_append(&report), Vec::<&crate::Change>::new());
assert_eq!(report.verdict, Verdict::Breaking);
assert_eq!(
width_change(&diff_refs_with(&[&old], &[&new], &[], &[])).map(|change| change.verdict),
Some(Verdict::Compatible)
);
}
#[test]
fn an_appended_enum_set_flag_that_keeps_the_width_draws_no_width_line() {
let old = enum_set_with(&[0, 3], v2::IntWidth::U8);
let new = enum_set_with(&[0, 3, 7], v2::IntWidth::U8);
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(width_change(&report), None);
assert_eq!(strict_append(&report), Vec::<&crate::Change>::new());
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn a_flag_inserted_below_the_highest_bit_is_a_generic_break_with_no_strict_enum_line() {
let old = enum_set_with(&[0, 7], v2::IntWidth::U8);
let new = enum_set_with(&[0, 7, 3], v2::IntWidth::U8);
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert!(
report
.changes
.iter()
.any(|change| change.category == Category::DeclAdded
&& change.verdict == Verdict::Breaking
&& change.path == "veh.cluster/Flags/F3"),
"{}",
render_text(&report)
);
assert_eq!(strict_append(&report), Vec::<&crate::Change>::new());
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn an_appended_enum_value_past_65535_widens_u16_to_u32() {
let (old, new) = (enum_of(&[0, 65_535]), enum_of(&[0, 65_535, 65_536]));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(
width_change(&report).map(|change| (
change.verdict,
change.before.as_deref(),
change.after.as_deref()
)),
Some((Verdict::Breaking, Some("u16"), Some("u32"))),
"{}",
render_text(&report)
);
}
fn enum_of(numbers: &[i64]) -> v2::Package {
let values = numbers
.iter()
.map(|&value| v2::EnumValue {
name: if value < 0 {
format!("Vm{}", -value)
} else {
format!("V{value}")
},
value,
..Default::default()
})
.collect();
with_decl(
"Mode",
v2::decl::Kind::EnumDef(v2::EnumDef {
values,
open: true,
..Default::default()
}),
)
}
#[test]
fn an_appended_enum_value_past_127_widens_a_signed_i8_to_i16() {
let (old, new) = (enum_of(&[-1, 0, 127]), enum_of(&[-1, 0, 127, 128]));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(
width_change(&report).map(|change| (
change.verdict,
change.before.as_deref(),
change.after.as_deref()
)),
Some((Verdict::Breaking, Some("i8"), Some("i16"))),
"{}",
render_text(&report)
);
}
fn width_of(
old: &v2::Package,
new: &v2::Package,
) -> Option<(Verdict, Option<String>, Option<String>)> {
width_change(&diff_refs_with(&[old], &[new], &[], &["podbuf"]))
.map(|change| (change.verdict, change.before.clone(), change.after.clone()))
}
fn width(before: &str, after: &str) -> Option<(Verdict, Option<String>, Option<String>)> {
Some((
Verdict::Breaking,
Some(before.to_string()),
Some(after.to_string()),
))
}
#[test]
fn values_appended_to_an_empty_enum_give_the_narrowest_signed_integer() {
assert_eq!(width_of(&enum_of(&[]), &enum_of(&[-1])), width("u8", "i8"));
assert_eq!(
width_of(&enum_of(&[]), &enum_of(&[-1, 200])),
width("u8", "i16")
);
}
#[test]
fn an_appended_enum_set_flag_at_bit_32_widens_u32_to_u64() {
assert_eq!(
width_of(
&enum_set_with(&[0, 31], v2::IntWidth::U32),
&enum_set_with(&[0, 31, 32], v2::IntWidth::U64)
),
width("u32", "u64")
);
}
#[test]
fn a_breaking_insert_that_changes_the_integer_draws_no_width_line() {
let (old, new) = (enum_of(&[0, 2]), enum_of(&[-1, 0, 2]));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert!(
report
.changes
.iter()
.any(|change| change.category == Category::DeclAdded
&& change.verdict == Verdict::Breaking),
"{}",
render_text(&report)
);
assert_eq!(width_change(&report), None, "{}", render_text(&report));
}
}
mod flatbuffers_width {
use ridl_ir::v2;
use crate::{Category, Change, DiffReport, Verdict, diff_refs_with, render_text};
fn package(name: &str, kind: v2::decl::Kind) -> v2::Package {
v2::Package {
name: "veh.cluster".to_string(),
decls: vec![v2::Decl {
name: name.to_string(),
kind: Some(kind),
..Default::default()
}],
..Default::default()
}
}
fn values(count: i64) -> Vec<v2::EnumValue> {
(0..count)
.map(|value| v2::EnumValue {
name: format!("V{value}"),
value,
..Default::default()
})
.collect()
}
fn enum_set_of(highest_bit: i64, open: bool) -> v2::Package {
let width = match highest_bit {
0..=7 => v2::IntWidth::U8,
8..=15 => v2::IntWidth::U16,
16..=31 => v2::IntWidth::U32,
_ => v2::IntWidth::U64,
};
package(
"Flags",
v2::decl::Kind::EnumSetDef(v2::EnumSetDef {
bits: values(highest_bit + 1),
width: width as i32,
open,
..Default::default()
}),
)
}
fn enum_of(count: i64, open: bool) -> v2::Package {
package(
"Mode",
v2::decl::Kind::EnumDef(v2::EnumDef {
values: values(count),
open,
..Default::default()
}),
)
}
fn of_category(report: &DiffReport, category: Category) -> Vec<&Change> {
report
.changes
.iter()
.filter(|change| change.category == category)
.collect()
}
#[test]
fn an_open_enum_set_flag_that_widens_the_scalar_is_a_flatbuffers_width_change() {
for (highest_bit, before, after) in
[(7, "u8", "u16"), (15, "u16", "u32"), (31, "u32", "u64")]
{
let (old, new) = (
enum_set_of(highest_bit, true),
enum_set_of(highest_bit + 1, true),
);
let report = diff_refs_with(&[&old], &[&new], &[], &["flatbuffers"]);
let lines = of_category(&report, Category::FlatbuffersWidthChange);
assert_eq!(lines.len(), 1, "{}", render_text(&report));
assert_eq!(lines[0].path, "veh.cluster/Flags");
assert_eq!(lines[0].verdict, Verdict::Breaking);
assert_eq!(lines[0].before.as_deref(), Some(before));
assert_eq!(lines[0].after.as_deref(), Some(after));
assert_eq!(report.verdict, Verdict::Breaking);
let report = diff_refs_with(&[&old], &[&new], &[], &[]);
let lines = of_category(&report, Category::FlatbuffersWidthChange);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].verdict, Verdict::Compatible);
assert_eq!(report.verdict, Verdict::Compatible);
if highest_bit == 7 {
assert!(render_text(&report).contains("breaking under flatbuffers:\n [compatible] flatbuffers_width_change veh.cluster/Flags: u8 -> u16"), "{}", render_text(&report));
}
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
let lines = of_category(&report, Category::FlatbuffersWidthChange);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].verdict, Verdict::Compatible);
}
}
#[test]
fn an_open_enum_set_flag_that_keeps_the_scalar_draws_no_flatbuffers_line() {
for highest_bit in [6, 14, 30] {
let (old, new) = (
enum_set_of(highest_bit, true),
enum_set_of(highest_bit + 1, true),
);
let report = diff_refs_with(&[&old], &[&new], &[], &["flatbuffers"]);
assert!(
of_category(&report, Category::FlatbuffersWidthChange).is_empty(),
"{}",
render_text(&report)
);
assert_eq!(report.verdict, Verdict::Compatible);
}
}
#[test]
fn an_open_enum_value_past_255_keeps_the_flatbuffers_long() {
let (old, new) = (enum_of(256, true), enum_of(257, true));
let report = diff_refs_with(&[&old], &[&new], &[], &["flatbuffers"]);
assert!(
of_category(&report, Category::FlatbuffersWidthChange).is_empty(),
"{}",
render_text(&report)
);
assert_eq!(report.verdict, Verdict::Compatible);
let report = diff_refs_with(&[&old], &[&new], &[], &["flatbuffers", "podbuf"]);
assert_eq!(of_category(&report, Category::PodbufWidthChange).len(), 1);
assert!(of_category(&report, Category::FlatbuffersWidthChange).is_empty());
}
#[test]
fn a_strict_enum_set_append_that_widens_is_a_strict_enum_append_only() {
let (old, new) = (enum_set_of(7, false), enum_set_of(8, false));
let report = diff_refs_with(&[&old], &[&new], &[], &["flatbuffers"]);
assert_eq!(of_category(&report, Category::StrictEnumAppend).len(), 1);
assert!(
of_category(&report, Category::FlatbuffersWidthChange).is_empty(),
"{}",
render_text(&report)
);
assert_eq!(report.changes.len(), 1);
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn the_flatbuffers_width_rule_reads_only_compatible_appends() {
let (old, new) = (enum_set_of(7, false), enum_set_of(8, false));
let mut pair = vec![Change {
path: "veh.cluster/Flags/V8".to_string(),
category: Category::DeclAdded,
verdict: Verdict::Breaking,
before: None,
after: Some("8".to_string()),
}];
let changes = crate::walk::flatbuffers_width_changes(&pair, &old, &new);
assert!(changes.is_empty(), "{changes:?}");
pair[0].verdict = Verdict::Compatible;
let changes = crate::walk::flatbuffers_width_changes(&pair, &old, &new);
assert_eq!(changes.len(), 1, "{changes:?}");
assert_eq!(changes[0].category, Category::FlatbuffersWidthChange);
}
#[test]
fn both_width_rules_report_under_their_own_headings() {
let (old, new) = (enum_set_of(7, true), enum_set_of(8, true));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf", "flatbuffers"]);
for category in [
Category::PodbufWidthChange,
Category::FlatbuffersWidthChange,
] {
let lines = of_category(&report, category);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].verdict, Verdict::Breaking);
}
assert_eq!(
crate::heading(Category::FlatbuffersWidthChange),
Some("breaking under flatbuffers")
);
let text = render_text(&report);
assert!(
text.find("breaking under podbuf:").unwrap()
< text.find("breaking under flatbuffers:").unwrap(),
"{text}"
);
assert_eq!(
crate::heading(Category::PodbufWidthChange),
Some("breaking under podbuf")
);
}
}
mod proto3_width {
use ridl_ir::v2;
use crate::{Category, Change, DiffReport, Verdict, diff_refs_with, render_text};
fn package(name: &str, kind: v2::decl::Kind) -> v2::Package {
v2::Package {
name: "veh.cluster".to_string(),
decls: vec![v2::Decl {
name: name.to_string(),
kind: Some(kind),
..Default::default()
}],
..Default::default()
}
}
fn values(count: i64) -> Vec<v2::EnumValue> {
(0..count)
.map(|value| v2::EnumValue {
name: format!("V{value}"),
value,
..Default::default()
})
.collect()
}
fn enum_set_of(highest_bit: i64, open: bool) -> v2::Package {
let width = match highest_bit {
0..=7 => v2::IntWidth::U8,
8..=15 => v2::IntWidth::U16,
16..=31 => v2::IntWidth::U32,
_ => v2::IntWidth::U64,
};
package(
"Flags",
v2::decl::Kind::EnumSetDef(v2::EnumSetDef {
bits: values(highest_bit + 1),
width: width as i32,
open,
..Default::default()
}),
)
}
fn enum_of(count: i64, open: bool) -> v2::Package {
package(
"Mode",
v2::decl::Kind::EnumDef(v2::EnumDef {
values: values(count),
open,
..Default::default()
}),
)
}
fn of_category(report: &DiffReport, category: Category) -> Vec<&Change> {
report
.changes
.iter()
.filter(|change| change.category == category)
.collect()
}
#[test]
fn an_open_enum_set_flag_at_bit_32_is_a_proto3_width_change() {
let (old, new) = (enum_set_of(31, true), enum_set_of(32, true));
let report = diff_refs_with(&[&old], &[&new], &[], &["proto3"]);
let lines = of_category(&report, Category::Proto3WidthChange);
assert_eq!(lines.len(), 1, "{}", render_text(&report));
assert_eq!(lines[0].path, "veh.cluster/Flags");
assert_eq!(lines[0].verdict, Verdict::Breaking);
assert_eq!(lines[0].before.as_deref(), Some("uint32"));
assert_eq!(lines[0].after.as_deref(), Some("uint64"));
assert_eq!(report.verdict, Verdict::Breaking);
let report = diff_refs_with(&[&old], &[&new], &[], &[]);
let lines = of_category(&report, Category::Proto3WidthChange);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].verdict, Verdict::Compatible);
assert_eq!(report.verdict, Verdict::Compatible);
assert!(render_text(&report).contains("breaking under proto3:\n [compatible] proto3_width_change veh.cluster/Flags: uint32 -> uint64"), "{}", render_text(&report));
let report = diff_refs_with(&[&old], &[&new], &[], &["flatbuffers"]);
let lines = of_category(&report, Category::Proto3WidthChange);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].verdict, Verdict::Compatible);
let (old, new) = (enum_set_of(7, true), enum_set_of(40, true));
let report = diff_refs_with(&[&old], &[&new], &[], &["proto3"]);
let lines = of_category(&report, Category::Proto3WidthChange);
assert_eq!(lines.len(), 1, "{}", render_text(&report));
assert_eq!(lines[0].before.as_deref(), Some("uint32"));
assert_eq!(lines[0].after.as_deref(), Some("uint64"));
}
#[test]
fn an_open_enum_set_flag_below_bit_32_draws_no_proto3_line() {
for highest_bit in [7, 15, 30] {
let (old, new) = (
enum_set_of(highest_bit, true),
enum_set_of(highest_bit + 1, true),
);
let report = diff_refs_with(&[&old], &[&new], &[], &["proto3"]);
assert!(
of_category(&report, Category::Proto3WidthChange).is_empty(),
"{}",
render_text(&report)
);
assert_eq!(report.verdict, Verdict::Compatible);
}
}
#[test]
fn an_open_enum_value_never_draws_a_proto3_line() {
let (old, new) = (enum_of(256, true), enum_of(257, true));
let report = diff_refs_with(&[&old], &[&new], &[], &["proto3"]);
assert!(
of_category(&report, Category::Proto3WidthChange).is_empty(),
"{}",
render_text(&report)
);
assert_eq!(report.verdict, Verdict::Compatible);
let report = diff_refs_with(&[&old], &[&new], &[], &["proto3", "podbuf"]);
assert_eq!(of_category(&report, Category::PodbufWidthChange).len(), 1);
assert!(of_category(&report, Category::Proto3WidthChange).is_empty());
}
#[test]
fn a_strict_enum_set_append_at_bit_32_is_a_strict_enum_append_only() {
let (old, new) = (enum_set_of(31, false), enum_set_of(32, false));
let report = diff_refs_with(&[&old], &[&new], &[], &["proto3"]);
assert_eq!(of_category(&report, Category::StrictEnumAppend).len(), 1);
assert!(
of_category(&report, Category::Proto3WidthChange).is_empty(),
"{}",
render_text(&report)
);
assert_eq!(report.changes.len(), 1);
assert_eq!(report.verdict, Verdict::Breaking);
}
#[test]
fn the_proto3_width_rule_reads_only_compatible_appends() {
let (old, new) = (enum_set_of(31, false), enum_set_of(32, false));
let mut pair = vec![Change {
path: "veh.cluster/Flags/V32".to_string(),
category: Category::DeclAdded,
verdict: Verdict::Breaking,
before: None,
after: Some("32".to_string()),
}];
let changes = crate::walk::proto3_width_changes(&pair, &old, &new);
assert!(changes.is_empty(), "{changes:?}");
pair[0].verdict = Verdict::Compatible;
let changes = crate::walk::proto3_width_changes(&pair, &old, &new);
assert_eq!(changes.len(), 1, "{changes:?}");
assert_eq!(changes[0].category, Category::Proto3WidthChange);
}
#[test]
fn all_three_width_rules_report_under_their_own_headings() {
let (old, new) = (enum_set_of(31, true), enum_set_of(32, true));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf", "flatbuffers", "proto3"]);
for category in [
Category::PodbufWidthChange,
Category::FlatbuffersWidthChange,
Category::Proto3WidthChange,
] {
let lines = of_category(&report, category);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].verdict, Verdict::Breaking);
}
assert_eq!(
crate::heading(Category::Proto3WidthChange),
Some("breaking under proto3")
);
let text = render_text(&report);
assert!(
text.find("breaking under flatbuffers:").unwrap()
< text.find("breaking under proto3:").unwrap(),
"{text}"
);
}
}
mod enum_open {
use ridl_ir::v2;
use crate::{
Category, Change, DiffReport, Verdict, diff_packages, diff_refs_with, explain, render_text,
};
fn values(count: i64) -> Vec<v2::EnumValue> {
(0..count)
.map(|value| v2::EnumValue {
name: format!("V{value}"),
value,
..Default::default()
})
.collect()
}
fn package(kind: v2::decl::Kind) -> v2::Package {
v2::Package {
name: "veh.cluster".to_string(),
decls: vec![v2::Decl {
name: "Mode".to_string(),
kind: Some(kind),
..Default::default()
}],
..Default::default()
}
}
fn enum_of(count: i64, open: bool) -> v2::Package {
package(v2::decl::Kind::EnumDef(v2::EnumDef {
values: values(count),
open,
..Default::default()
}))
}
fn enum_set_of(count: i64, open: bool) -> v2::Package {
let width = match count {
0..=8 => v2::IntWidth::U8,
9..=16 => v2::IntWidth::U16,
17..=32 => v2::IntWidth::U32,
_ => v2::IntWidth::U64,
};
package(v2::decl::Kind::EnumSetDef(v2::EnumSetDef {
bits: values(count),
width: width as i32,
open,
..Default::default()
}))
}
type Build = fn(i64, bool) -> v2::Package;
fn shapes() -> [(&'static str, Build); 2] {
[("enum", enum_of), ("enum set", enum_set_of)]
}
fn of_category(report: &DiffReport, category: Category) -> Vec<&Change> {
report
.changes
.iter()
.filter(|change| change.category == category)
.collect()
}
#[test]
fn an_append_to_a_strict_declaration_is_breaking_without_an_encoding() {
for (label, build) in shapes() {
let (old, new) = (build(3, false), build(4, false));
for encodings in [&[][..], &["flatbuffers"], &["proto3"], &["podbuf"]] {
let report = diff_refs_with(&[&old], &[&new], &[], encodings);
let lines = of_category(&report, Category::StrictEnumAppend);
assert_eq!(
lines.len(),
1,
"{label} {encodings:?}: {}",
render_text(&report)
);
assert_eq!(lines[0].verdict, Verdict::Breaking);
assert_eq!(lines[0].path, "veh.cluster/Mode");
assert_eq!(lines[0].after.as_deref(), Some("V3"));
assert_eq!(report.verdict, Verdict::Breaking, "{label} {encodings:?}");
assert!(
of_category(&report, Category::DeclAdded).is_empty(),
"{label}: the append draws no decl_added line: {}",
render_text(&report)
);
}
}
}
#[test]
fn diff_packages_folds_a_strict_append_into_one_breaking_line() {
for (label, build) in shapes() {
let report = diff_packages(&build(3, false), &build(5, false));
let lines = of_category(&report, Category::StrictEnumAppend);
assert_eq!(lines.len(), 1, "{label}: {}", render_text(&report));
assert_eq!(lines[0].verdict, Verdict::Breaking, "{label}");
assert_eq!(lines[0].after.as_deref(), Some("V3, V4"), "{label}");
assert_eq!(report.verdict, Verdict::Breaking, "{label}");
assert!(
of_category(&report, Category::DeclAdded).is_empty(),
"{label}: {}",
render_text(&report)
);
}
}
#[test]
fn diff_packages_judges_an_open_append_compatible() {
for (label, build) in shapes() {
let report = diff_packages(&build(3, true), &build(5, true));
assert_eq!(
report.verdict,
Verdict::Compatible,
"{label}: {}",
render_text(&report)
);
assert!(of_category(&report, Category::StrictEnumAppend).is_empty());
}
}
#[test]
fn an_append_to_an_internal_strict_enum_is_a_breaking_strict_append() {
for (label, build) in shapes() {
let internal = |mut package: v2::Package| {
package.decls[0].visibility = v2::Visibility::Internal as i32;
package
};
let (old, new) = (internal(build(3, false)), internal(build(4, false)));
let report = diff_refs_with(&[&old], &[&new], &[], &[]);
let lines = of_category(&report, Category::StrictEnumAppend);
assert_eq!(lines.len(), 1, "{label}: {}", render_text(&report));
assert_eq!(lines[0].verdict, Verdict::Breaking, "{label}");
assert_eq!(report.verdict, Verdict::Breaking, "{label}");
}
}
#[test]
fn several_appended_members_draw_one_line_naming_them() {
for (label, build) in shapes() {
let (old, new) = (build(3, false), build(5, false));
let report = diff_refs_with(&[&old], &[&new], &[], &[]);
let lines = of_category(&report, Category::StrictEnumAppend);
assert_eq!(lines.len(), 1, "{label}");
assert_eq!(lines[0].after.as_deref(), Some("V3, V4"), "{label}");
}
}
#[test]
fn an_append_to_an_open_declaration_is_compatible() {
for (label, build) in shapes() {
let (old, new) = (build(3, true), build(4, true));
for encodings in [&[][..], &["flatbuffers"], &["proto3"], &["podbuf"]] {
let report = diff_refs_with(&[&old], &[&new], &[], encodings);
assert_eq!(
report.verdict,
Verdict::Compatible,
"{label} {encodings:?}: {}",
render_text(&report)
);
assert!(of_category(&report, Category::StrictEnumAppend).is_empty());
}
}
}
#[test]
fn a_strict_append_is_no_longer_reported_as_the_podbuf_category() {
for (_, build) in shapes() {
let (old, new) = (build(3, false), build(4, false));
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert!(of_category(&report, Category::PodbufStrictEnumAppend).is_empty());
}
}
#[test]
fn opening_is_compatible_and_closing_is_breaking() {
for (label, build) in shapes() {
let report = diff_refs_with(&[&build(3, false)], &[&build(3, true)], &[], &[]);
let lines = of_category(&report, Category::EnumOpened);
assert_eq!(lines.len(), 1, "{label}: {}", render_text(&report));
assert_eq!(lines[0].path, "veh.cluster/Mode");
assert_eq!(lines[0].verdict, Verdict::Compatible);
assert_eq!(report.verdict, Verdict::Compatible, "{label}");
assert_eq!(report.changes.len(), 1, "{label}: {}", render_text(&report));
let report = diff_refs_with(&[&build(3, true)], &[&build(3, false)], &[], &[]);
let lines = of_category(&report, Category::EnumClosed);
assert_eq!(lines.len(), 1, "{label}: {}", render_text(&report));
assert_eq!(lines[0].verdict, Verdict::Breaking);
assert_eq!(report.verdict, Verdict::Breaking, "{label}");
assert_eq!(report.changes.len(), 1, "{label}: {}", render_text(&report));
}
}
#[test]
fn opening_with_an_append_reports_both_and_is_compatible() {
for (label, build) in shapes() {
let report = diff_refs_with(&[&build(3, false)], &[&build(4, true)], &[], &[]);
assert_eq!(
of_category(&report, Category::EnumOpened).len(),
1,
"{label}"
);
assert_eq!(
report.verdict,
Verdict::Compatible,
"{label}: {}",
render_text(&report)
);
}
}
#[test]
fn closing_with_an_append_is_breaking() {
for (label, build) in shapes() {
let report = diff_refs_with(&[&build(3, true)], &[&build(4, false)], &[], &[]);
assert_eq!(
of_category(&report, Category::EnumClosed).len(),
1,
"{label}"
);
assert_eq!(
of_category(&report, Category::StrictEnumAppend).len(),
1,
"{label}"
);
assert_eq!(report.verdict, Verdict::Breaking);
}
}
#[test]
fn an_unchanged_declaration_reports_nothing() {
for (_, build) in shapes() {
for open in [false, true] {
let report = diff_refs_with(&[&build(3, open)], &[&build(3, open)], &[], &[]);
assert_eq!(report.verdict, Verdict::Identical);
}
}
}
#[test]
fn the_strict_append_explanation_names_the_open_keyword() {
assert!(explain(Category::StrictEnumAppend).contains("open enum"));
assert!(explain(Category::StrictEnumAppend).contains("breaking"));
}
fn enum_with(open: bool, numbered: &[(&str, i64)]) -> v2::Package {
package(v2::decl::Kind::EnumDef(v2::EnumDef {
values: numbered
.iter()
.map(|(name, value)| v2::EnumValue {
name: (*name).to_string(),
value: *value,
..Default::default()
})
.collect(),
open,
..Default::default()
}))
}
fn added(path: &str) -> Change {
Change {
path: path.to_string(),
category: Category::DeclAdded,
verdict: Verdict::Compatible,
before: None,
after: None,
}
}
#[test]
fn the_folded_line_sits_at_the_position_of_the_first_replaced_line() {
let (old, new) = (enum_of(3, false), enum_of(5, false));
let pair = vec![
added("veh.cluster/Before"),
added("veh.cluster/Mode/V3"),
added("veh.cluster/Between"),
added("veh.cluster/Mode/V4"),
added("veh.cluster/After"),
];
let folded = crate::walk::fold_strict_enum_appends(pair, &old, &new);
let shape: Vec<(&str, Category)> = folded
.iter()
.map(|change| (change.path.as_str(), change.category))
.collect();
assert_eq!(
shape,
vec![
("veh.cluster/Before", Category::DeclAdded),
("veh.cluster/Mode", Category::StrictEnumAppend),
("veh.cluster/Between", Category::DeclAdded),
("veh.cluster/After", Category::DeclAdded),
]
);
assert_eq!(folded[1].after.as_deref(), Some("V3, V4"));
}
#[test]
fn a_strict_insert_below_the_highest_number_stays_decl_added() {
let old = enum_with(false, &[("A", 0), ("C", 2)]);
let new = enum_with(false, &[("A", 0), ("B", 1), ("C", 2)]);
let report = diff_refs_with(&[&old], &[&new], &[], &[]);
assert!(
of_category(&report, Category::StrictEnumAppend).is_empty(),
"{}",
render_text(&report)
);
let lines = of_category(&report, Category::DeclAdded);
assert_eq!(lines.len(), 1, "{}", render_text(&report));
assert_eq!(lines[0].verdict, Verdict::Breaking);
}
#[test]
fn an_open_append_of_a_negative_value_is_a_podbuf_width_change() {
let old = enum_with(true, &[]);
let new = enum_with(true, &[("Below", -1)]);
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
let lines = of_category(&report, Category::PodbufWidthChange);
assert_eq!(lines.len(), 1, "{}", render_text(&report));
assert_eq!(lines[0].verdict, Verdict::Breaking);
let report = diff_refs_with(&[&old], &[&new], &[], &[]);
assert_eq!(report.verdict, Verdict::Compatible);
}
#[test]
fn an_open_append_that_fits_the_podbuf_width_is_compatible() {
for (label, build) in shapes() {
let (old, new) = if label == "enum" {
(build(255, true), build(256, true))
} else {
(build(7, true), build(8, true))
};
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
assert_eq!(
report.verdict,
Verdict::Compatible,
"{label}: {}",
render_text(&report)
);
assert!(
of_category(&report, Category::PodbufWidthChange).is_empty(),
"{label}: {}",
render_text(&report)
);
assert!(
of_category(&report, Category::StrictEnumAppend).is_empty(),
"{label}: {}",
render_text(&report)
);
}
}
#[test]
fn an_append_to_an_open_enum_that_widens_the_integer_is_a_podbuf_width_change() {
for (label, build) in shapes() {
let (old, new) = if label == "enum" {
(build(256, true), build(257, true))
} else {
(build(8, true), build(9, true))
};
let report = diff_refs_with(&[&old], &[&new], &[], &["podbuf"]);
let lines = of_category(&report, Category::PodbufWidthChange);
assert_eq!(lines.len(), 1, "{label}: {}", render_text(&report));
assert_eq!(lines[0].verdict, Verdict::Breaking);
assert_eq!(report.verdict, Verdict::Breaking);
let report = diff_refs_with(&[&old], &[&new], &[], &[]);
assert_eq!(report.verdict, Verdict::Compatible, "{label}");
}
}
}
#[test]
fn a_reply_changed_from_a_type_to_a_stream_of_it_keeps_its_current_category() {
let query = |reply: v2::FieldType| {
interaction(
"latest",
1,
v2::decl::Kind::QueryDef(v2::QueryDef {
return_type: Some(v2::ReturnType {
kind: Some(v2::return_type::Kind::Value(reply)),
}),
..Default::default()
}),
)
};
let plain = v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Named("FaultEvent".to_string())),
};
let streamed = v2::FieldType {
optional: false,
kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
element: Some(v2::stream_type::Element::Named("FaultEvent".to_string())),
})),
};
let old = pkg("veh.cluster", interface("Faults", vec![query(plain)]));
let new = pkg("veh.cluster", interface("Faults", vec![query(streamed)]));
let report = diff_packages(&old, &new);
assert_eq!(report.changes.len(), 1, "{:?}", report.changes);
assert_eq!(report.changes[0].category, Category::ReturnChanged);
assert_eq!(report.changes[0].verdict, Verdict::Breaking);
}