feat(plugin): name-addressed XRecord CRUD on the HostApi (API v8)

Add create/read/update/delete/list for XRecord objects under
named-object-dictionary paths to the plugin HostApi, with five additive
PluginRequest/PluginResponse variants, dispatch through the shared
handler, in-process HostSession methods and both out-of-process proxies.

- dictionary paths walk from the NOD root; create seeds missing
  dictionaries (ensure_image_dictionary bootstrap) and refuses existing
  names; update refuses missing ones; refusals precede the undo step
- entry group codes validated (1..=369, never 5/105); payload content is
  the plugin's own
- each mutating call is one undo step: the pending history snapshot
  restores the objects map byte-exactly, auto-created dictionaries
  included (tested)
- typed entries survive DWG and DXF write/reload round-trips (tested);
  update drops verbatim raw bytes so saves emit the new content
- bump API_VERSION 7 -> 8 with the build.rs version_info in lockstep,
  mirroring the v7 addition; MIN_SUPPORTED stays 3
This commit is contained in:
Sebastian committed 2026-10-11 19:57:20 +02:00
1 parent 6cde0e02d8
commit ca2f0c9857
11 files changed
+1212 -3

No files matched your search

@@ -388,6 +388,111 @@ mod tests {
);
}
#[test]
fn xrecord_request_and_response_roundtrip() {
// The five XRecord request variants (API v8) serialize with their
// dictionary paths and typed entries, and the responses answer with
// handles, entries, records and names.
let entries = vec![
crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}"),
crate::host::XRecordEntry::int16(70, 3),
];
let bytes = bincode::serialize(&PluginRequest::CreateXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
entries: entries.clone(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::CreateXRecord { dictionary_path, name, entries: decoded } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
);
let bytes = bincode::serialize(&PluginRequest::ReadXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::ReadXRecord { dictionary_path, name } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
);
let bytes = bincode::serialize(&PluginRequest::UpdateXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
entries: entries.clone(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::UpdateXRecord { dictionary_path, name, entries: decoded } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
);
let bytes = bincode::serialize(&PluginRequest::DeleteXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::DeleteXRecord { dictionary_path, name } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
);
let bytes = bincode::serialize(&PluginRequest::ListXRecords {
dictionary_path: vec!["CVIFC".to_owned()],
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::ListXRecords { dictionary_path } if
dictionary_path == vec!["CVIFC".to_owned()])
);
let bytes = bincode::serialize(&PluginResponse::XRecordCreateResult(Ok(
codec::Handle::new(42),
)))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordCreateResult(Ok(handle)) if handle == codec::Handle::new(42))
);
let bytes =
bincode::serialize(&PluginResponse::XRecordEntries(Ok(entries.clone()))).unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordEntries(Ok(decoded)) if decoded == entries)
);
let bytes = bincode::serialize(&PluginResponse::XRecordUpdateResult(Ok(
codec::Handle::new(42),
)))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordUpdateResult(Ok(handle)) if handle == codec::Handle::new(42))
);
let bytes = bincode::serialize(&PluginResponse::XRecordNames(Ok(vec!["alpha".to_owned()])))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordNames(Ok(names)) if names == vec!["alpha".to_owned()])
);
let deleted = crate::host::XRecord::named("alpha");
let bytes =
bincode::serialize(&PluginResponse::XRecordDeleteResult(Ok(deleted.clone()))).unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordDeleteResult(Ok(record)) if record == deleted)
);
let bytes = bincode::serialize(&PluginResponse::XRecordEntries(Err(
"XRecord reads are not supported by this host".to_owned(),
)))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordEntries(Err(error)) if error.contains("not supported"))
);
}
#[test]
fn plugin_notification_roundtrip() {
let frame = PluginToHostV4::Notification(NotificationEnvelope {