Add demo_plugin and plugin host integration tests

Minimal in-tree add-on (DP_HELLO) validates registry, ribbon merge, and try_dispatch. Five new lib tests; EntryKind gains PartialEq for test helpers.
This commit is contained in:
Michael Flynn 2026-06-09 13:52:15 -04:00
commit 82993bc1db
12 changed files with 214 additions and 2 deletions

View file

@ -51,7 +51,7 @@ cargo build
cargo test --lib
```
No domain plugin is registered in this branch; existing core tests should pass unchanged.
A minimal **`demo_plugin`** add-on registers at compile time for smoke tests (`DP_HELLO` command, Demo Plugin ribbon tab). Remove or gate it before release if maintainers prefer zero in-tree add-ons.
## Review questions

36
docs/issue-78-comment.txt Normal file
View file

@ -0,0 +1,36 @@
@HakanSeven12 @schoeller — following up with a concrete architecture proposal and an implementation ready for review.
### Proposal
I've drafted a **QGIS-style add-on model** on my fork:
- **Spec:** [docs/plugin-architecture.md](https://github.com/mf4633/OpenCADStudio/blob/feature/plugin-host/docs/plugin-architecture.md)
- **Scaffold:** [docs/plugin-template/](https://github.com/mf4633/OpenCADStudio/tree/feature/plugin-host/docs/plugin-template)
- **Framework PR:** #80 — **host only, no Storm Sewer in core**
**Three layers:** host core → add-on package (plugin.toml, ribbon, commands) → optional headless engine crate. Domain data lives on DWG entities (XDATA), not a proprietary project DB.
**Phase 1 (PR #80):** in-process plugins via inventory::submit!(PluginRegistration), HostSession API, per-document plugin state, command routing without editing commands.rs.
**Phase 2:** user install folder + dynamic .dll/.so with the same plugin.toml.
### Storm Sewer (separate)
Storm Sewer stays on a separate branch as the reference consumer — not in core: [feature/storm-sewer-module](https://github.com/mf4633/OpenCADStudio/tree/feature/storm-sewer-module).
### Re: script languages (@schoeller, #29)
Agree this shouldn't be either/or. Suggested sequencing:
1. Native Rust add-ons + stable HostSession / ocs_plugin_api
2. Python (or similar) as **bindings over that same API** — one extension surface, two authoring paths
Phase 1 defers embedded scripting until the native API is stable.
### Questions for maintainers
1. ocs_plugin_api as a workspace crate with semver — OK?
2. Should the main repo ship **zero** discipline modules, or optional built-ins for dev?
3. Priority: extract API crate (1b) vs dynamic loading (2)?
— Michael

View file

@ -1475,6 +1475,22 @@ impl OpenCADStudio {
app
}
#[cfg(test)]
pub(crate) fn new_for_test() -> Self {
Self::new()
}
#[cfg(test)]
pub(crate) fn command_history_info(&self) -> Vec<String> {
use crate::ui::command_line::EntryKind;
self.command_line
.history
.iter()
.filter(|e| e.kind == EntryKind::Info)
.map(|e| e.text.clone())
.collect()
}
/// Boot function for `iced::daemon`: returns initial state plus a task that
/// opens the primary application window.
fn boot() -> (Self, Task<Message>) {

View file

@ -0,0 +1,11 @@
use crate::plugin::host::HostSession;
pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool {
match cmd {
"DP_HELLO" => {
host.push_info("Hello from demo_plugin (plugin host OK).");
true
}
_ => false,
}
}

View file

@ -0,0 +1,14 @@
use crate::plugin::manifest::{ApiVersion, PluginManifest};
pub const PLUGIN_ID: &str = "opencad.demo_plugin";
pub static MANIFEST: PluginManifest = PluginManifest {
id: PLUGIN_ID,
name: "Demo Plugin",
version: "0.1.0",
description: "Minimal add-on for plugin-host integration tests",
api_version: ApiVersion::CURRENT,
ribbon_order: 99,
xdata_apps: &[],
command_prefixes: &["DP_"],
};

View file

@ -0,0 +1,47 @@
// Minimal in-tree add-on — validates plugin host on `feature/plugin-host`.
pub mod dispatch;
pub mod manifest;
pub mod plugin;
pub mod register;
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
inventory::submit!(crate::command::CommandRegistration {
names: &["DP_HELLO"]
});
pub struct DemoPluginModule;
impl CadModule for DemoPluginModule {
fn id(&self) -> &'static str {
"demo_plugin"
}
fn title(&self) -> &'static str {
"Demo Plugin"
}
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
vec![RibbonGroup {
title: "Smoke",
tools: vec![RibbonItem::LargeTool(ToolDef {
id: "DP_HELLO",
label: "Hello",
icon: IconKind::Glyph(""),
event: ModuleEvent::Command("DP_HELLO".to_string()),
})],
}]
}
}
#[cfg(test)]
mod tests {
use crate::plugin::all_ribbon_modules;
#[test]
fn ribbon_tab_is_registered() {
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
assert!(titles.contains(&"Demo Plugin"), "tabs: {titles:?}");
}
}

View file

@ -0,0 +1,21 @@
use crate::plugin::host::{BuiltinPlugin, HostSession};
use crate::plugin::manifest::PluginManifest;
use super::dispatch;
use super::manifest;
pub struct DemoPlugin;
impl BuiltinPlugin for DemoPlugin {
fn manifest(&self) -> &'static PluginManifest {
&manifest::MANIFEST
}
fn ribbon(&self) -> Box<dyn crate::modules::CadModule> {
Box::new(super::DemoPluginModule)
}
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool {
dispatch::handle(host, cmd)
}
}

View file

@ -0,0 +1,13 @@
[plugin]
id = "opencad.demo_plugin"
name = "Demo Plugin"
version = "0.1.0"
description = "Minimal add-on for plugin-host integration tests"
author = "Open CAD Studio contributors"
license = "GPL-3.0-only"
[opencad]
api_version = 1
ribbon_order = 99
command_prefixes = ["DP_"]
xdata_apps = []

View file

@ -0,0 +1,7 @@
use super::plugin::DemoPlugin;
inventory::submit! {
crate::plugin::registry::PluginRegistration {
construct: || Box::new(DemoPlugin),
}
}

View file

@ -139,6 +139,7 @@ pub mod home;
pub mod insert;
pub mod model;
pub mod layout;
pub mod demo_plugin;
pub mod manage;
pub mod view;

View file

@ -40,3 +40,49 @@ pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bo
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::OpenCADStudio;
#[test]
fn discovers_registered_plugins() {
let plugins = all_plugins();
assert!(
!plugins.is_empty(),
"expected at least one PluginRegistration (demo_plugin)"
);
assert!(
plugins
.iter()
.any(|p| p.manifest().id == "opencad.demo_plugin"),
"demo_plugin missing; ids: {:?}",
plugins.iter().map(|p| p.manifest().id).collect::<Vec<_>>()
);
}
#[test]
fn addon_ribbon_tabs_merge_after_core() {
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
assert!(titles.contains(&"Demo Plugin"), "ribbon tabs: {titles:?}");
let core = core_registry::all_modules();
assert_eq!(titles.len(), core.len() + all_plugins().len());
}
#[test]
fn try_dispatch_routes_demo_command() {
let mut app = OpenCADStudio::new_for_test();
assert!(try_dispatch(&mut app, 0, "DP_HELLO"));
let info = app.command_history_info();
assert!(
info.iter().any(|t| t.contains("demo_plugin") && t.contains("plugin host OK")),
"info history: {info:?}"
);
}
#[test]
fn unknown_plugin_command_falls_through() {
let mut app = OpenCADStudio::new_for_test();
assert!(!try_dispatch(&mut app, 0, "DP_NOPE"));
}
}

View file

@ -49,7 +49,7 @@ pub struct HistoryEntry {
pub created_at: Instant,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EntryKind {
Command,
Output,