use anyhow::{bail, Context, Result};
use graphix_compiler::expr::{ExprId, ModuleResolver};
use graphix_compiler::BindId;
use graphix_package_core::testing::{self, RegisterFn, TestCtx};
use graphix_rt::{Callable, CompRes, GXEvent, NoExt, Ref};
use netidx::{protocol::valarray::ValArray, publisher::Value};
use poolshark::global::GPooled;
use std::time::Duration;
use tokio::sync::mpsc;
use crate::widgets::{self, GuiW, Message, MessageShell};
mod canvas_test;
mod chart_test;
mod clipboard_test;
mod data_table_test;
mod interaction_test;
mod widgets_test;
const TEST_REGISTER: &[RegisterFn] = &[
<graphix_package_core::P as graphix_package::Package<NoExt>>::register,
<graphix_package_array::P as graphix_package::Package<NoExt>>::register,
<graphix_package_map::P as graphix_package::Package<NoExt>>::register,
<graphix_package_str::P as graphix_package::Package<NoExt>>::register,
<graphix_package_sys::P as graphix_package::Package<NoExt>>::register,
<crate::P as graphix_package::Package<NoExt>>::register,
];
struct GuiTestHarness {
_ctx: TestCtx,
gx: graphix_rt::GXHandle<NoExt>,
#[allow(dead_code)]
compiled: CompRes<NoExt>,
rx: mpsc::Receiver<GPooled<Vec<GXEvent>>>,
widget: GuiW<NoExt>,
rt_handle: tokio::runtime::Handle,
watched: fxhash::FxHashMap<ExprId, Value>,
watch_names: fxhash::FxHashMap<String, ExprId>,
_refs: Vec<Ref<NoExt>>,
_callables: Vec<Callable<NoExt>>,
}
impl GuiTestHarness {
async fn new(code: &str) -> Result<Self> {
let (tx, mut rx) = mpsc::channel(100);
let tbl = fxhash::FxHashMap::from_iter([(
netidx_core::path::Path::from("/test.gx"),
arcstr::ArcStr::from(code),
)]);
let resolver = ModuleResolver::VFS(tbl);
let ctx = testing::init_with_resolvers(tx, TEST_REGISTER, vec![resolver]).await?;
let gx = ctx.rt.clone();
let compiled = gx
.compile(arcstr::literal!("{ mod test; test::result }"))
.await
.context("compile graphix code")?;
let expr_id = compiled.exprs[0].id;
let initial_value = wait_for_update(&mut rx, expr_id).await?;
let widget = widgets::compile(gx.clone(), initial_value)
.await
.context("compile widget tree")?;
let rt_handle = tokio::runtime::Handle::current();
Ok(Self {
_ctx: ctx,
gx,
compiled,
rx,
widget,
rt_handle,
watched: fxhash::FxHashMap::default(),
watch_names: fxhash::FxHashMap::default(),
_refs: Vec::new(),
_callables: Vec::new(),
})
}
async fn drain(&mut self) -> Result<bool> {
let mut changed = false;
let timeout = tokio::time::sleep(Duration::from_millis(100));
tokio::pin!(timeout);
loop {
tokio::select! {
biased;
Some(mut batch) = self.rx.recv() => {
for event in batch.drain(..) {
if let GXEvent::Updated(id, v) = event {
if self.watched.contains_key(&id) {
self.watched.insert(id, v.clone());
}
changed |= self.widget.handle_update(
&self.rt_handle, id, &v
)?;
}
}
timeout.as_mut().reset(
tokio::time::Instant::now() + Duration::from_millis(50)
);
}
_ = &mut timeout => break,
}
}
Ok(changed)
}
async fn watch(&mut self, name: &str) -> Result<Value> {
let bid = find_bind_id(&self.compiled.env, name)
.with_context(|| format!("watch: lookup {name}"))?;
let r = self
.gx
.compile_ref(bid)
.await
.with_context(|| format!("watch: compile ref to {name}"))?;
let initial = r.last.clone().unwrap_or(Value::Null);
self.watched.insert(r.id, initial.clone());
self.watch_names.insert(name.to_string(), r.id);
self._refs.push(r);
self.drain().await?;
Ok(initial)
}
fn get_watched(&self, name: &str) -> Option<&Value> {
self.watch_names.get(name).and_then(|eid| self.watched.get(eid))
}
async fn dispatch_calls(&mut self, msgs: &[Message]) -> Result<()> {
let mut pending: std::collections::VecDeque<Message> = msgs.iter().cloned().collect();
while let Some(msg) = pending.pop_front() {
match msg {
Message::Nop => {}
Message::Call(id, args) => {
self.gx.call(id, args)?;
}
Message::ColumnResizeStart(_)
| Message::ColumnResizeMove(_)
| Message::ColumnResizeEnd => {}
other => {
let mut shell = MessageShell::new(iced_core::Point::ORIGIN);
self.widget.on_message(&other, &mut shell);
pending.extend(shell.out.drain(..));
}
}
}
self.drain().await?;
Ok(())
}
fn view(&self) -> crate::widgets::IcedElement<'_> {
self.widget.view()
}
#[allow(dead_code)]
fn before_view(&mut self) -> bool {
self.widget.before_view()
}
#[allow(dead_code)]
async fn wait_until<F>(&mut self, mut pred: F, within: Duration, why: &str) -> Result<()>
where
F: FnMut(&mut Self) -> bool,
{
let deadline = tokio::time::Instant::now() + within;
let mut iters = 0;
loop {
self.drain().await?;
self.before_view();
iters += 1;
if pred(self) {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
bail!(
"wait_until timed out after {iters} iterations / {:?}: {why}",
within,
);
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn dt_snapshot(&self) -> crate::widgets::DataTableSnapshot {
self.widget
.data_table_snapshot()
.expect("widget is not a DataTableW")
}
fn dt(&self) -> &crate::widgets::data_table::DataTableW<NoExt> {
self.widget
.as_any()
.downcast_ref::<crate::widgets::data_table::DataTableW<NoExt>>()
.expect("widget is not a DataTableW")
}
fn dt_mut(&mut self) -> &mut crate::widgets::data_table::DataTableW<NoExt> {
self.widget
.as_any_mut()
.downcast_mut::<crate::widgets::data_table::DataTableW<NoExt>>()
.expect("widget is not a DataTableW")
}
async fn call_callback(
&mut self,
id: graphix_rt::CallableId,
args: ValArray,
) -> Result<()> {
self.gx.call(id, args)?;
self.drain().await?;
Ok(())
}
async fn compile_named_callable(
&mut self,
name: &str,
) -> Result<graphix_rt::CallableId> {
let bid = find_bind_id(&self.compiled.env, name)
.with_context(|| format!("compile_named_callable: lookup {name}"))?;
let r = self.gx.compile_ref(bid).await
.with_context(|| format!("compile_named_callable: compile_ref {name}"))?;
let val = r.last.clone()
.with_context(|| format!("compile_named_callable: no value for {name}"))?;
let cb = self.gx.compile_callable(val).await
.with_context(|| format!("compile_named_callable: compile_callable {name}"))?;
let id = cb.id();
self._refs.push(r);
self._callables.push(cb);
Ok(id)
}
}
async fn wait_for_update(
rx: &mut mpsc::Receiver<GPooled<Vec<GXEvent>>>,
target_id: ExprId,
) -> Result<Value> {
let timeout = tokio::time::sleep(Duration::from_secs(5));
tokio::pin!(timeout);
loop {
tokio::select! {
biased;
Some(mut batch) = rx.recv() => {
for event in batch.drain(..) {
if let GXEvent::Updated(id, v) = event {
if id == target_id {
return Ok(v);
}
}
}
}
_ = &mut timeout => bail!("timeout waiting for initial widget value"),
}
}
}
fn find_bind_id(env: &graphix_compiler::env::Env, name: &str) -> Result<BindId> {
use netidx::path::Path;
let parts: Vec<&str> = name.split("::").collect();
let (module, var) = match parts.as_slice() {
[module, var] => (*module, *var),
_ => bail!("expected module::var, got {name}"),
};
let suffix = format!("/{module}");
for (scope, vars) in &env.binds {
if Path::as_ref(&scope.0).ends_with(&suffix) {
if let Some(bid) = vars.get(var) {
return Ok(*bid);
}
}
}
bail!("no binding {name} found in env")
}
use iced_core::{clipboard, mouse, Event, Point, Size};
use iced_runtime::user_interface::{self, UserInterface};
use iced_wgpu::graphics::Shell;
use iced_wgpu::wgpu;
use tokio::sync::OnceCell;
struct HeadlessGpu {
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
format: wgpu::TextureFormat,
}
static HEADLESS_GPU: OnceCell<HeadlessGpu> = OnceCell::const_new();
async fn headless_gpu() -> &'static HeadlessGpu {
HEADLESS_GPU
.get_or_init(|| async {
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::from_env().unwrap_or(wgpu::Backends::PRIMARY),
..Default::default()
});
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: None,
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(a) => a,
Err(_) => instance
.request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: None,
force_fallback_adapter: true,
..Default::default()
})
.await
.expect("no GPU adapter available (not even software fallback)"),
};
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor::default())
.await
.expect("failed to create GPU device");
HeadlessGpu {
adapter,
device,
queue,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
}
})
.await
}
impl HeadlessGpu {
fn create_renderer(&self) -> widgets::Renderer {
let engine = iced_wgpu::Engine::new(
&self.adapter,
self.device.clone(),
self.queue.clone(),
self.format,
None,
Shell::headless(),
);
iced_wgpu::Renderer::new(
engine,
iced_core::Font::DEFAULT,
iced_core::Pixels(16.0),
)
}
}
struct InteractionHarness {
inner: GuiTestHarness,
renderer: widgets::Renderer,
cache: user_interface::Cache,
viewport: Size,
cursor_position: Point,
}
impl InteractionHarness {
async fn new(code: &str) -> Result<Self> {
Self::with_viewport(code, Size::new(300.0, 50.0)).await
}
async fn with_viewport(code: &str, viewport: Size) -> Result<Self> {
let gpu = headless_gpu().await;
let renderer = gpu.create_renderer();
let inner = GuiTestHarness::new(code).await?;
Ok(Self {
inner,
renderer,
cache: user_interface::Cache::default(),
viewport,
cursor_position: Point::ORIGIN,
})
}
fn process_events(&mut self, events: &[Event]) -> Vec<Message> {
let element = self.inner.widget.view();
let cache = std::mem::take(&mut self.cache);
let mut ui =
UserInterface::build(element, self.viewport, cache, &mut self.renderer);
let mut messages = Vec::new();
let mut clipboard = clipboard::Null;
let cursor = mouse::Cursor::Available(self.cursor_position);
let (_state, _statuses) =
ui.update(events, cursor, &mut self.renderer, &mut clipboard, &mut messages);
self.cache = ui.into_cache();
messages
}
#[allow(dead_code)]
async fn drain(&mut self) -> Result<bool> {
self.inner.drain().await
}
#[allow(dead_code)]
fn resize(&mut self, viewport: Size) {
self.viewport = viewport;
self.cache = user_interface::Cache::default();
let _ = self.process_events(&[]);
}
fn view(&mut self) -> crate::widgets::IcedElement<'_> {
let _ = self.process_events(&[]);
self.inner.view()
}
#[allow(dead_code)]
fn before_view(&mut self) -> bool {
self.inner.before_view()
}
#[allow(dead_code)]
fn viewport(&self) -> Size {
self.viewport
}
async fn watch(&mut self, name: &str) -> Result<Value> {
self.inner.watch(name).await
}
fn get_watched(&self, name: &str) -> Option<&Value> {
self.inner.get_watched(name)
}
async fn dispatch_calls(&mut self, msgs: &[Message]) -> Result<()> {
self.inner.dispatch_calls(msgs).await
}
fn click(&mut self, pos: Point) -> Vec<Message> {
self.cursor_position = pos;
let mut all = Vec::new();
all.extend(self.process_events(&[Event::Mouse(mouse::Event::CursorMoved {
position: pos,
})]));
all.extend(self.process_events(&[Event::Mouse(mouse::Event::ButtonPressed(
mouse::Button::Left,
))]));
all.extend(self.process_events(&[Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
))]));
all
}
#[allow(dead_code)]
fn click_center(&mut self) -> Vec<Message> {
let center = Point::new(self.viewport.width / 2.0, self.viewport.height / 2.0);
self.click(center)
}
#[allow(dead_code)]
fn click_at(&mut self, frac_x: f32, frac_y: f32) -> Vec<Message> {
let pos = Point::new(self.viewport.width * frac_x, self.viewport.height * frac_y);
self.click(pos)
}
fn type_text(&mut self, text: &str) -> Vec<Message> {
use iced_core::keyboard;
let mut all_msgs = Vec::new();
for ch in text.chars() {
let s: iced_core::SmolStr = ch.to_string().into();
all_msgs.extend(self.process_events(&[Event::Keyboard(
keyboard::Event::KeyPressed {
key: keyboard::Key::Character(s.clone()),
modified_key: keyboard::Key::Character(s.clone()),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some(s),
repeat: false,
},
)]));
}
all_msgs
}
fn press_key(&mut self, named: iced_core::keyboard::key::Named) -> Vec<Message> {
use iced_core::keyboard;
self.process_events(&[Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})])
}
fn release_key(&mut self, named: iced_core::keyboard::key::Named) -> Vec<Message> {
use iced_core::keyboard;
self.process_events(&[Event::Keyboard(keyboard::Event::KeyReleased {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
})])
}
fn scroll(&mut self, delta_x: f32, delta_y: f32) -> Vec<Message> {
self.process_events(&[Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: delta_x, y: delta_y },
})])
}
fn move_cursor(&mut self, pos: Point) -> Vec<Message> {
self.cursor_position = pos;
self.process_events(&[Event::Mouse(mouse::Event::CursorMoved { position: pos })])
}
fn process_editor_actions(&mut self, msgs: &[Message]) -> Vec<(CallableId, Value)> {
let mut out = Vec::new();
for m in msgs {
if let Message::EditorAction(_, _) = m {
let mut shell = MessageShell::new(Point::ORIGIN);
self.inner.widget.on_message(m, &mut shell);
for emitted in shell.out.drain(..) {
if let Message::Call(cid, args) = emitted {
if let Some(v) = args.into_iter().next() {
out.push((cid, v));
}
}
}
}
}
out
}
fn drag_horizontal(&mut self, from: Point, to_x: f32, steps: u32) -> Vec<Message> {
let mut all_msgs = Vec::new();
self.cursor_position = from;
all_msgs.extend(self.process_events(&[Event::Mouse(
mouse::Event::CursorMoved { position: from },
)]));
all_msgs.extend(self.process_events(&[Event::Mouse(
mouse::Event::ButtonPressed(mouse::Button::Left),
)]));
let dx = (to_x - from.x) / steps as f32;
for i in 1..=steps {
let pos = Point::new(from.x + dx * i as f32, from.y);
self.cursor_position = pos;
all_msgs.extend(self.process_events(&[Event::Mouse(
mouse::Event::CursorMoved { position: pos },
)]));
}
all_msgs.extend(self.process_events(&[Event::Mouse(
mouse::Event::ButtonReleased(mouse::Button::Left),
)]));
all_msgs
}
}
use graphix_rt::CallableId;
fn expect_call(msgs: &[Message]) -> CallableId {
let calls: Vec<_> = msgs
.iter()
.filter_map(|m| match m {
Message::Call(id, _) => Some(*id),
_ => None,
})
.collect();
assert_eq!(calls.len(), 1, "expected exactly one Call message, got {}", calls.len());
calls[0]
}
fn expect_call_with_args(
msgs: &[Message],
pred: impl Fn(&ValArray) -> bool,
) -> CallableId {
let calls: Vec<_> = msgs
.iter()
.filter_map(|m| match m {
Message::Call(id, args) if pred(args) => Some(*id),
_ => None,
})
.collect();
assert!(!calls.is_empty(), "expected a Call message matching predicate, got none");
calls[0]
}