feat(plugin-api): entity-class display declarations on the manifest (API v10)

Plugins can now declare the entity classes they identify via XDATA, and the
host resolves them to display-only type labels while the declaring plugin is
loaded.

- Append `BuiltinPlugin::entity_classes()` (a guarded trailing vtable slot,
  the same additive pattern as `on_load` in v5) returning a &'static
  `EntityClasses { app_id, class_prefix, labels }`. The static
  `PluginManifest` struct is deliberately untouched: older cdylibs share it
  by pointer and its layout is frozen ABI since v1, so adding a field would
  read past their allocation.
- `OwnedEntityClasses` travels on the registered manifest as
  `OwnedPluginManifest.entity_classes` with `#[serde(default)]`, so manifests
  serialized before the field keep decoding (older runners tolerate it
  both ways).
- The runner folds the declaration into the GetManifest registration only
  when the loaded cdylib's exported `ocs_plugin_api_version` symbol reports
  >= 10 — an older binary's vtable predates the slot, so it is never called;
  its entities simply keep their native type names.
- `PluginManager::entity_class_declarations` exposes the live set, filtered
  to alive, non-disabled plugins in load order. The registered manifests are
  the only source and nothing is cached: not installed, disabled,
  cap-excluded (`OCS_PLUGIN_MAX_API_VERSION`), failed at load, removed, or a
  dead process all contribute nothing.
- Embedded version info `api_version` follows the bump to 10;
  `API_VERSION_MIN_SUPPORTED` stays 3.

Tests: runner fold (v10 folds, pre-v10 skipped, `entity_classes()` panic is
contained and reported), manager filtering (disabled / dead / undeclaring
skipped; declarations disappear once the process dies — verified with a real
killed child), bincode registration round-trip, and older-manifest serde
default.
This commit is contained in:
Sebastian committed 2026-10-15 22:39:09 +02:00
1 parent 859070403c
commit 60eb7b086e
9 files changed
+502 -13

No files matched your search

+1 -1
View File
@@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) {
"acadrust_version": opencadcodec.version.to_string(),
"acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()),
"rustc_version": rustc_version,
"api_version": 9,
"api_version": 10,
"api_version_min_supported": 3,
"build_timestamp": build_timestamp,
});
+16 -1
View File
@@ -16,7 +16,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::manifest::PluginManifest;
use crate::manifest::{EntityClasses, PluginManifest};
use crate::ribbon::CadModule;
// Re-export the opencadcodec crate and the types that appear in the HostApi trait
@@ -659,6 +659,21 @@ pub trait BuiltinPlugin: Send + Sync {
/// Added in API v5. The runner only calls this for plugins that report API
/// major 5 or newer.
fn on_load(&mut self, _host: &mut dyn HostApi) {}
/// Entity-class display declarations (API v10): the XDATA
/// class-identity keys the plugin stamps on entities, mapped to the
/// display labels the host shows while this plugin is loaded (e.g. the
/// Properties panel title becomes `"Linear Element (Line)"`). Display-only
/// — the labels never round-trip the drawing and never govern behavior;
/// entities carry only the stable identity keys in their XDATA.
///
/// The default (`None`) declares no entity classes. The runner only calls
/// this for plugins whose exported `ocs_plugin_api_version` symbol reports
/// API major 10 or newer, so older cdylibs are never touched past their
/// vtable.
fn entity_classes(&self) -> Option<&'static EntityClasses> {
None
}
}
/// A point-driven interactive command a plugin starts via
+65
View File
@@ -158,6 +158,71 @@ mod tests {
}
}
#[test]
fn registered_manifest_roundtrips_entity_classes_over_bincode() {
// The registration handshake is length-framed bincode, so the manifest
// and its API-v10 entity-class declaration must survive exactly that
// wire format.
let manifest = crate::ribbon::owned::OwnedPluginManifest {
id: "opencad.civil_ifc".to_string(),
name: "Civil IFC".to_string(),
version: "0.1.0".to_string(),
description: "test".to_string(),
api_version: 10,
ribbon_order: 50,
xdata_apps: vec!["CVIFC".to_string()],
command_prefixes: vec![],
entity_classes: Some(crate::ribbon::owned::OwnedEntityClasses {
app_id: "CVIFC".to_string(),
class_prefix: "CVIFC-CLASS:".to_string(),
labels: [
("LINE".to_string(), "Linear Element".to_string()),
(
"CIRCULARARC".to_string(),
"Circular Arc Element".to_string(),
),
]
.into_iter()
.collect(),
}),
};
let resp = HostResponse::Manifest(manifest);
let bytes = bincode::serialize(&resp).unwrap();
let got: HostResponse = bincode::deserialize(&bytes).unwrap();
match got {
HostResponse::Manifest(m) => {
let classes = m.entity_classes.expect("declaration survives");
assert_eq!(classes.app_id, "CVIFC");
assert_eq!(classes.class_prefix, "CVIFC-CLASS:");
assert_eq!(
classes.labels.get("LINE").map(String::as_str),
Some("Linear Element")
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn manifest_entity_classes_default_when_absent() {
// A manifest serialized before the field existed (an older runner)
// decodes with the declaration defaulting to `None`.
let older = r#"{
"id": "old.plugin",
"name": "Old",
"version": "0.1.0",
"description": "test",
"api_version": 9,
"ribbon_order": 0,
"xdata_apps": [],
"command_prefixes": []
}"#;
let m: crate::ribbon::owned::OwnedPluginManifest =
serde_json::from_str(older).expect("older manifest decodes");
assert_eq!(m.id, "old.plugin");
assert!(m.entity_classes.is_none());
}
#[test]
fn v4_additions_preserve_legacy_discriminants() {
fn discriminant<T: serde::Serialize>(value: &T) -> u32 {
+19 -1
View File
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use crate::host::{CommandSource, CommandStep, HostSettingValue};
use crate::manifest::ApiVersion;
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
use crate::ribbon::owned::{OwnedEntityClasses, OwnedPluginManifest, OwnedRibbonGroup};
pub use crate::host::PreviewWire;
pub use codec::xdata::{ExtendedDataRecord, XDataValue};
@@ -307,6 +307,24 @@ impl From<&'static crate::manifest::PluginManifest> for OwnedPluginManifest {
ribbon_order: m.ribbon_order,
xdata_apps: m.xdata_apps.iter().map(|s| s.to_string()).collect(),
command_prefixes: m.command_prefixes.iter().map(|s| s.to_string()).collect(),
// The declaration does not live on the static manifest struct (its
// layout is frozen ABI, shared by pointer with older cdylibs); the
// runner folds the API-v10 trait accessor in after conversion.
entity_classes: None,
}
}
}
impl From<&'static crate::manifest::EntityClasses> for OwnedEntityClasses {
fn from(c: &'static crate::manifest::EntityClasses) -> Self {
Self {
app_id: c.app_id.to_string(),
class_prefix: c.class_prefix.to_string(),
labels: c
.labels
.iter()
.map(|(key, label)| (key.to_string(), label.to_string()))
.collect(),
}
}
}
+34 -2
View File
@@ -27,8 +27,11 @@
/// `delete_xrecord` / `list_xrecords`). v9 appends the reference-only entity
/// designation (`HostApi::set_reference_only`): display-only geometry a plugin
/// still owns and refreshes while the built-in edit paths treat it like a
/// locked layer.
pub const API_VERSION: u32 = 9;
/// locked layer. v10 appends `BuiltinPlugin::entity_classes`: plugin-declared
/// XDATA class-identity display labels, folded into the registered manifest.
/// Display-only; the host consults the declaration only while the declaring
/// plugin is loaded, and entities without one keep their native type names.
pub const API_VERSION: u32 = 10;
/// Oldest plugin API major the current host still loads. This keeps previously
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
@@ -77,6 +80,35 @@ pub fn host_accepts_plugin_version(plugin_major: u32) -> bool {
plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= effective_max_api_version()
}
/// A plugin's declaration of the entity classes it identifies via XDATA,
/// surfaced by the host as display-only type labels (the Properties panel
/// title and the automation `display_type` field).
///
/// The split is deliberate: the *identity* is a stable class KEY stamped into
/// the entity's XDATA (one string value per entity, e.g. `"MYAPP-CLASS:LINE"`)
/// and round-trips with the drawing; the *display labels* live in this
/// declaration, so they are plugin-localizable and fixable without rewriting
/// drawings. The declaration is consulted only while the declaring plugin is
/// loaded — a plugin that is not installed, disabled, failed to load, or whose
/// process has died contributes nothing, and its entities display as their
/// bare native type.
///
/// Declared through [`crate::host::BuiltinPlugin::entity_classes`] (API v10);
/// see `docs/plugin-architecture.md` for the full convention.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EntityClasses {
/// XDATA application name whose records carry the class-identity keys
/// (e.g. `"CVIFC"`). Should be one of the manifest's `xdata_apps` so the
/// host knows the plugin writes under this id.
pub app_id: &'static str,
/// Prefix that marks a string XDATA value as a class-identity key
/// (e.g. `"CVIFC-CLASS:"`). The remainder of the value is the class key.
pub class_prefix: &'static str,
/// Class key → display label (e.g. `"LINE"` → `"Linear Element"`), in
/// declaration order. Display text never round-trips the drawing.
pub labels: &'static [(&'static str, &'static str)],
}
#[cfg(test)]
mod tests {
use crate::test_lock::ENV_LOCK;
+69
View File
@@ -1150,6 +1150,74 @@ fn generate_token() -> Result<String, PluginError> {
Ok(out)
}
/// In-crate test support: build a `PluginProcess` whose fields are otherwise
/// private to this module, so sibling test modules (`process::manager`) can
/// put one in a `PluginManager` without spawning a real runner.
#[cfg(all(test, feature = "host"))]
impl PluginProcess {
/// A process backed by a real sleeping child process and a local socket
/// pair, carrying the given manifest (no cdylib, no runner). The
/// runner-side peer socket is dropped immediately; nothing reads it.
pub(crate) fn fake(manifest: OwnedPluginManifest) -> Self {
use interprocess::local_socket::traits::Stream as StreamTrait;
static COUNTER: AtomicU64 = AtomicU64::new(0);
let name = format!(
"ocs_plugin_fake_process_test_{}_{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
);
let name_ref = name
.clone()
.to_ns_name::<GenericNamespaced>()
.expect("valid name");
let listener = ListenerOptions::new()
.name(name_ref)
.create_sync()
.expect("listener");
let client_name = name.clone();
let client_thread: std::thread::JoinHandle<Stream> = std::thread::spawn(move || {
StreamTrait::connect(client_name.to_ns_name::<GenericNamespaced>().unwrap())
.expect("connect")
});
let host_stream = listener.accept().expect("accept");
drop(client_thread.join().expect("client thread"));
Self {
stream: Mutex::new(Some(host_stream)),
v4: None,
child: Mutex::new(Some(sleepy_test_child())),
id: manifest.id.clone(),
manifest,
ribbon: vec![],
io_lines: Mutex::new(None),
}
}
}
/// A child that lives ~30 s — long enough to read as "alive", short enough to
/// never hang a leaked test. Shared by `PluginProcess::fake` and this module's
/// own tests.
#[cfg(all(test, feature = "host"))]
fn sleepy_test_child() -> Child {
#[cfg(windows)]
{
std::process::Command::new("cmd")
.arg("/c")
.arg("ping -n 30 127.0.0.1")
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn sleep")
}
#[cfg(not(windows))]
{
std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep")
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1352,6 +1420,7 @@ mod timeout_tests {
ribbon_order: 0,
xdata_apps: vec![],
command_prefixes: vec![],
entity_classes: None,
}
}
+123 -1
View File
@@ -21,7 +21,7 @@ use std::time::Duration;
use crate::host::{HostApi, HostNotification, PluginNotification};
use crate::process::{PluginError, PluginProcess};
use crate::ribbon::owned::{to_shared_module, SharedCadModule};
use crate::ribbon::owned::{to_shared_module, OwnedEntityClasses, SharedCadModule};
/// Callback the host installs to receive plugin-to-host notifications.
pub type NotificationHandler = Arc<dyn Fn(&str, Option<u64>, PluginNotification) + Send + Sync>;
@@ -197,6 +197,29 @@ impl PluginManager {
out
}
/// Entity-class declarations of every alive, non-disabled plugin that
/// made one (API v10), in load order, as `(plugin id, declaration)`.
///
/// The host consults this at display time only — never cached — so the
/// result always mirrors the live loaded set: a plugin that failed to
/// load, is disabled, was removed, or whose process has died contributes
/// nothing, and entities it stamped fall back to their native type names.
pub fn entity_class_declarations<F: Fn(&str) -> bool>(
&self,
is_disabled: F,
) -> Vec<(String, OwnedEntityClasses)> {
let mut out = Vec::new();
for p in &self.plugins {
if is_disabled(p.process.id()) || !p.process.is_alive() {
continue;
}
if let Some(classes) = p.process.manifest().entity_classes.clone() {
out.push((p.process.id().to_string(), classes));
}
}
out
}
/// Drain any plugin→host requests that have arrived asynchronously across
/// all loaded plugins. Errors are logged; this is best-effort.
pub fn drain_requests(
@@ -358,4 +381,103 @@ mod tests {
assert!(!result.handled);
assert!(result.started.is_none());
}
fn manifest_with_entity_classes(id: &str) -> crate::ribbon::owned::OwnedPluginManifest {
crate::ribbon::owned::OwnedPluginManifest {
id: id.to_string(),
name: format!("{id} name"),
version: "0.1.0".to_string(),
description: "test".to_string(),
api_version: 10,
ribbon_order: 0,
xdata_apps: vec![],
command_prefixes: vec![],
entity_classes: Some(OwnedEntityClasses {
app_id: "CVIFC".to_string(),
class_prefix: "CVIFC-CLASS:".to_string(),
labels: [("LINE".to_string(), "Linear Element".to_string())]
.into_iter()
.collect(),
}),
}
}
fn plain_manifest(id: &str) -> crate::ribbon::owned::OwnedPluginManifest {
crate::ribbon::owned::OwnedPluginManifest {
id: id.to_string(),
name: format!("{id} name"),
version: "0.1.0".to_string(),
description: "test".to_string(),
api_version: 10,
ribbon_order: 0,
xdata_apps: vec![],
command_prefixes: vec![],
entity_classes: None,
}
}
#[test]
fn entity_class_declarations_skip_disabled_and_undeclaring_plugins() {
let mut manager = PluginManager::new();
manager.plugins.push(LoadedPlugin {
process: Arc::new(PluginProcess::fake(manifest_with_entity_classes(
"test.live",
))),
module: to_shared_module("test.live".into(), "Live".into(), vec![]),
});
manager.plugins.push(LoadedPlugin {
process: Arc::new(PluginProcess::fake(plain_manifest("test.plain"))),
module: to_shared_module("test.plain".into(), "Plain".into(), vec![]),
});
manager.plugins.push(LoadedPlugin {
process: Arc::new(PluginProcess::fake(manifest_with_entity_classes(
"test.off",
))),
module: to_shared_module("test.off".into(), "Off".into(), vec![]),
});
// Alive + enabled + declaring → reported, with the declaration intact.
let declarations = manager.entity_class_declarations(|id| id == "test.off");
assert_eq!(
declarations.len(),
1,
"only the live, enabled plugin reports"
);
assert_eq!(declarations[0].0, "test.live");
assert_eq!(declarations[0].1.app_id, "CVIFC");
assert_eq!(declarations[0].1.class_prefix, "CVIFC-CLASS:");
assert_eq!(
declarations[0].1.labels.get("LINE").map(String::as_str),
Some("Linear Element")
);
// Everything disabled → nothing contributes.
assert!(manager.entity_class_declarations(|_| true).is_empty());
}
#[test]
fn entity_class_declarations_revert_when_the_plugin_process_dies() {
let mut manager = PluginManager::new();
manager.plugins.push(LoadedPlugin {
process: Arc::new(PluginProcess::fake(manifest_with_entity_classes(
"test.live",
))),
module: to_shared_module("test.live".into(), "Live".into(), vec![]),
});
assert_eq!(
manager.entity_class_declarations(|_| false).len(),
1,
"declaration visible while the process is alive"
);
manager.plugins[0]
.process
.shutdown_and_wait(Duration::from_secs(5))
.expect("child shut down");
assert!(
manager.entity_class_declarations(|_| false).is_empty(),
"a dead plugin process must contribute no declarations"
);
}
}
+17
View File
@@ -123,6 +123,18 @@ fn collect_item_command_ids(item: &OwnedRibbonItem, out: &mut Vec<String>) {
}
}
/// Owned mirror of [`crate::manifest::EntityClasses`]: the entity-class
/// declaration as it travels on the registered manifest across the IPC wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OwnedEntityClasses {
/// XDATA application name whose records carry the class-identity keys.
pub app_id: String,
/// Prefix that marks a string XDATA value as a class-identity key.
pub class_prefix: String,
/// Class key → display label.
pub labels: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OwnedPluginManifest {
pub id: String,
@@ -133,6 +145,11 @@ pub struct OwnedPluginManifest {
pub ribbon_order: i32,
pub xdata_apps: Vec<String>,
pub command_prefixes: Vec<String>,
/// Entity-class display declarations (API v10), folded in by the runner
/// for plugins that declare them. `#[serde(default)]` keeps manifests
/// serialized before the field existed decoding as `None`.
#[serde(default)]
pub entity_classes: Option<OwnedEntityClasses>,
}
impl From<IconKind> for OwnedIconKind {
+158 -7
View File
@@ -29,7 +29,7 @@ use crate::ipc::protocol::{
};
use crate::ipc::transport::{recv, send};
use crate::ipc::v4::client::{RunnerFrame, V4Client};
use crate::ribbon::owned::OwnedRibbonGroup;
use crate::ribbon::owned::{OwnedEntityClasses, OwnedPluginManifest, OwnedRibbonGroup};
/// Entry point for the plugin runner child process.
///
@@ -59,7 +59,7 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::err
if version >= 5 {
invoke_on_load(&mut *plugin, &client, &interactive)?;
}
run_v4(&mut *plugin, &interactive, client)
run_v4(&mut *plugin, &interactive, client, version)
} else {
let client = IpcClient::connect(socket_name)?;
eprintln!("[runner] connected to host");
@@ -106,6 +106,7 @@ fn run_v4(
plugin: &mut dyn BuiltinPlugin,
interactive: &InteractiveRegistry,
client: V4Client,
api_version: u32,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
// Drain any host notifications and dispatch them to the plugin's
@@ -119,9 +120,14 @@ fn run_v4(
match client.recv_runner_frame_timeout(std::time::Duration::from_millis(50)) {
Ok(RunnerFrame::Request { id, payload }) => {
if let Some(resp) =
handle_host_request_v4(&mut *plugin, interactive, &client, id, payload)
{
if let Some(resp) = handle_host_request_v4(
&mut *plugin,
interactive,
&client,
id,
api_version,
payload,
) {
client.send_response(id, resp)?;
}
}
@@ -277,12 +283,37 @@ fn handle_host_request_v4(
interactive: &InteractiveRegistry,
client: &V4Client,
id: u64,
api_version: u32,
req: HostRequest,
) -> Option<HostResponse> {
match req {
HostRequest::GetManifest => {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) {
Ok(m) => Some(HostResponse::Manifest(m.into())),
Ok(m) => {
let mut manifest = OwnedPluginManifest::from(m);
// API v10: fold the plugin's entity-class declarations into
// the registered manifest. The call is guarded by the ABI
// version the cdylib was actually built against (the
// exported symbol, not the manifest's claim): an older
// cdylib's vtable predates the slot, so touching it would
// be undefined behavior. Its entities simply keep their
// native type names.
if api_version >= 10 {
let classes =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
plugin.entity_classes()
}));
manifest.entity_classes = match classes {
Ok(classes) => classes.map(OwnedEntityClasses::from),
Err(_) => {
return Some(HostResponse::Error(
"plugin entity_classes() panicked".to_string(),
))
}
};
}
Some(HostResponse::Manifest(manifest))
}
Err(_) => Some(HostResponse::Error(
"plugin manifest() panicked".to_string(),
)),
@@ -568,7 +599,7 @@ mod tests {
code: "1+1".to_string(),
tab_index: 0,
};
let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, req);
let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, 4, req);
match resp {
Some(HostResponse::Error(msg)) => {
assert!(msg.contains("does not support code execution"));
@@ -577,4 +608,124 @@ mod tests {
}
drop(host_stream);
}
static V10_MANIFEST: PluginManifest = PluginManifest {
id: "test.classed",
name: "Test Classed",
version: "0.1.0",
description: "test",
api_version: ApiVersion { major: 10 },
ribbon_order: 0,
xdata_apps: &["CVIFC"],
command_prefixes: &[],
};
static TEST_ENTITY_CLASSES: crate::manifest::EntityClasses = crate::manifest::EntityClasses {
app_id: "CVIFC",
class_prefix: "CVIFC-CLASS:",
labels: &[
("LINE", "Linear Element"),
("CIRCULARARC", "Circular Arc Element"),
],
};
struct ClassedPlugin;
impl BuiltinPlugin for ClassedPlugin {
fn manifest(&self) -> &'static PluginManifest {
&V10_MANIFEST
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(EmptyModule)
}
fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool {
false
}
fn entity_classes(&self) -> Option<&'static crate::manifest::EntityClasses> {
Some(&TEST_ENTITY_CLASSES)
}
}
fn manifest_response_of(
plugin: &mut dyn BuiltinPlugin,
api_version: u32,
) -> Option<HostResponse> {
let (host_stream, runner_stream) = connect_pair();
let client = V4Client::from_stream_for_test(runner_stream);
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
let resp = handle_host_request_v4(
plugin,
&interactive,
&client,
99,
api_version,
HostRequest::GetManifest,
);
drop(host_stream);
resp
}
#[test]
fn get_manifest_folds_entity_classes_for_v10_plugin() {
let mut plugin = ClassedPlugin;
match manifest_response_of(&mut plugin, 10) {
Some(HostResponse::Manifest(manifest)) => {
let classes = manifest.entity_classes.expect("declaration folded in");
assert_eq!(classes.app_id, "CVIFC");
assert_eq!(classes.class_prefix, "CVIFC-CLASS:");
assert_eq!(
classes.labels.get("LINE").map(String::as_str),
Some("Linear Element")
);
assert_eq!(
classes.labels.get("CIRCULARARC").map(String::as_str),
Some("Circular Arc Element")
);
}
other => panic!("expected manifest response, got {other:?}"),
}
}
#[test]
fn get_manifest_leaves_entity_classes_default_below_v10() {
// A cdylib whose exported ABI symbol predates v10 must not have the
// trait slot called, even if the in-process plugin object happens to
// implement it: the declaration stays `None` (native display types).
let mut plugin = ClassedPlugin;
match manifest_response_of(&mut plugin, 9) {
Some(HostResponse::Manifest(manifest)) => {
assert!(
manifest.entity_classes.is_none(),
"entity classes must not be folded in for pre-v10 plugins"
);
}
other => panic!("expected manifest response, got {other:?}"),
}
}
#[test]
fn get_manifest_reports_entity_classes_panic() {
struct PanickingClassesPlugin;
impl BuiltinPlugin for PanickingClassesPlugin {
fn manifest(&self) -> &'static PluginManifest {
&V10_MANIFEST
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(EmptyModule)
}
fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool {
false
}
fn entity_classes(&self) -> Option<&'static crate::manifest::EntityClasses> {
panic!("boom")
}
}
let mut plugin = PanickingClassesPlugin;
match manifest_response_of(&mut plugin, 10) {
Some(HostResponse::Error(msg)) => {
assert!(msg.contains("entity_classes() panicked"), "got: {msg}");
}
other => panic!("expected error response, got {other:?}"),
}
}
}