Skip to content

Commit 02e9577

Browse files
committed
feat: fluent command assertions
1 parent 09757ae commit 02e9577

16 files changed

Lines changed: 1831 additions & 290 deletions

File tree

crux_core/tests/effect_test_ext.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
//! Negative-case tests for the `EffectTestExt` extension trait that
2+
//! `#[effect]` generates on `Command<Effect, Event>`.
3+
4+
use serde::{Deserialize, Serialize};
5+
6+
mod app {
7+
use crux_core::{
8+
App, Command,
9+
render::{RenderOperation, render},
10+
};
11+
use crux_macros::effect;
12+
use serde::{Deserialize, Serialize};
13+
14+
use super::{PingOperation, ping};
15+
16+
#[derive(Debug, Serialize, Deserialize)]
17+
pub enum Event {
18+
Render,
19+
Ping,
20+
Both,
21+
Echo(()),
22+
}
23+
24+
#[effect]
25+
#[derive(Debug)]
26+
pub enum Effect {
27+
Render(RenderOperation),
28+
Ping(PingOperation),
29+
}
30+
31+
#[derive(Default)]
32+
pub struct PanicApp;
33+
34+
impl App for PanicApp {
35+
type Event = Event;
36+
type Model = ();
37+
type ViewModel = ();
38+
type Effect = Effect;
39+
40+
fn update(&self, event: Self::Event, _model: &mut Self::Model) -> Command<Effect, Event> {
41+
match event {
42+
Event::Render => render(),
43+
Event::Ping => ping().then_send(Event::Echo),
44+
Event::Both => render().and(ping().then_send(Event::Echo)),
45+
Event::Echo(_) => Command::done(),
46+
}
47+
}
48+
49+
fn view(&self, _model: &Self::Model) {}
50+
}
51+
}
52+
53+
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
54+
pub struct PingOperation;
55+
56+
impl crux_core::capability::Operation for PingOperation {
57+
type Output = ();
58+
}
59+
60+
fn ping() -> crux_core::command::RequestBuilder<
61+
app::Effect,
62+
app::Event,
63+
impl std::future::Future<Output = ()>,
64+
> {
65+
crux_core::Command::request_from_shell(PingOperation)
66+
}
67+
68+
use app::EffectTestExt as _;
69+
use crux_core::App as _;
70+
71+
#[test]
72+
#[should_panic(expected = "expected Render effect but no more effects remain")]
73+
fn expect_render_panics_on_empty_command() {
74+
let mut model = ();
75+
app::PanicApp
76+
.update(app::Event::Render, &mut model)
77+
.expect_render()
78+
.expect_render();
79+
}
80+
81+
#[test]
82+
#[should_panic(expected = "not a Render effect")]
83+
fn expect_render_panics_on_wrong_variant() {
84+
let mut model = ();
85+
app::PanicApp
86+
.update(app::Event::Ping, &mut model)
87+
.expect_render();
88+
}
89+
90+
#[test]
91+
#[should_panic(expected = "expected an event but got none")]
92+
fn then_event_panics_on_no_events() {
93+
let mut model = ();
94+
app::PanicApp
95+
.update(app::Event::Render, &mut model)
96+
.then_event(|_| {});
97+
}
98+
99+
#[test]
100+
fn resolve_ping_drives_resulting_event() {
101+
let mut model = ();
102+
let event = app::PanicApp
103+
.update(app::Event::Ping, &mut model)
104+
.resolve_ping(|_op| ())
105+
.expect_event();
106+
107+
assert!(matches!(event, app::Event::Echo(())));
108+
}
109+
110+
#[test]
111+
fn expect_only_render_succeeds_for_single_render() {
112+
let mut model = ();
113+
app::PanicApp
114+
.update(app::Event::Render, &mut model)
115+
.expect_only_render();
116+
}
117+
118+
#[test]
119+
#[should_panic(expected = "expected command to be done")]
120+
fn expect_only_render_panics_with_extra_effects() {
121+
let mut model = ();
122+
// `Both` emits Render plus a Ping; expect_only_render should reject the leftover.
123+
app::PanicApp
124+
.update(app::Event::Both, &mut model)
125+
.expect_only_render();
126+
}

crux_macros/src/effect/macro_impl.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,149 @@ pub fn effect_impl(args: Option<Ident>, input: ItemEnum) -> TokenStream {
152152
}
153153
});
154154

155+
let test_ext_trait_ident = format_ident!("{}TestExt", enum_ident);
156+
157+
let test_ext_trait_methods = effects.clone().map(|effect| {
158+
let effect_ident_str = effect.ident.to_string();
159+
let effect_ident_snake = effect_ident_str.to_snake_case();
160+
let operation = &effect.operation;
161+
let expect_fn = Ident::new(&format!("expect_{effect_ident_snake}"), Span::call_site());
162+
let expect_with_fn = Ident::new(
163+
&format!("expect_{effect_ident_snake}_with"),
164+
Span::call_site(),
165+
);
166+
let expect_only_fn = Ident::new(
167+
&format!("expect_only_{effect_ident_snake}"),
168+
Span::call_site(),
169+
);
170+
let expect_only_with_fn = Ident::new(
171+
&format!("expect_only_{effect_ident_snake}_with"),
172+
Span::call_site(),
173+
);
174+
let resolve_fn = Ident::new(&format!("resolve_{effect_ident_snake}"), Span::call_site());
175+
quote! {
176+
fn #expect_fn(&mut self) -> &mut Self;
177+
fn #expect_with_fn<F>(&mut self, f: F) -> &mut Self
178+
where
179+
F: ::core::ops::FnOnce(&#operation);
180+
fn #expect_only_fn(&mut self);
181+
fn #expect_only_with_fn<F>(&mut self, f: F)
182+
where
183+
F: ::core::ops::FnOnce(&#operation);
184+
fn #resolve_fn<F>(&mut self, f: F) -> &mut Self
185+
where
186+
F: ::core::ops::FnOnce(&#operation)
187+
-> <#operation as ::crux_core::capability::Operation>::Output;
188+
}
189+
});
190+
191+
let test_ext_impl_methods = effects.clone().map(|effect| {
192+
let effect_ident_str = effect.ident.to_string();
193+
let effect_ident_snake = effect_ident_str.to_snake_case();
194+
let operation = &effect.operation;
195+
let expect_fn = Ident::new(&format!("expect_{effect_ident_snake}"), Span::call_site());
196+
let expect_with_fn = Ident::new(
197+
&format!("expect_{effect_ident_snake}_with"),
198+
Span::call_site(),
199+
);
200+
let expect_only_fn = Ident::new(
201+
&format!("expect_only_{effect_ident_snake}"),
202+
Span::call_site(),
203+
);
204+
let expect_only_with_fn = Ident::new(
205+
&format!("expect_only_{effect_ident_snake}_with"),
206+
Span::call_site(),
207+
);
208+
let resolve_fn = Ident::new(&format!("resolve_{effect_ident_snake}"), Span::call_site());
209+
let no_more_msg = format!("expected {effect_ident_str} effect but no more effects remain");
210+
quote! {
211+
#[track_caller]
212+
fn #expect_fn(&mut self) -> &mut Self {
213+
let effect = self.effects().next()
214+
.unwrap_or_else(|| panic!(#no_more_msg));
215+
let _ = effect.#expect_fn();
216+
self
217+
}
218+
219+
#[track_caller]
220+
fn #expect_with_fn<F>(&mut self, f: F) -> &mut Self
221+
where
222+
F: ::core::ops::FnOnce(&#operation),
223+
{
224+
let effect = self.effects().next()
225+
.unwrap_or_else(|| panic!(#no_more_msg));
226+
let req = effect.#expect_fn();
227+
f(&req.operation);
228+
self
229+
}
230+
231+
#[track_caller]
232+
fn #expect_only_fn(&mut self) {
233+
let effect = self.effects().next()
234+
.unwrap_or_else(|| panic!(#no_more_msg));
235+
let _ = effect.#expect_fn();
236+
self.expect_no_effect_or_events();
237+
}
238+
239+
#[track_caller]
240+
fn #expect_only_with_fn<F>(&mut self, f: F)
241+
where
242+
F: ::core::ops::FnOnce(&#operation),
243+
{
244+
let effect = self.effects().next()
245+
.unwrap_or_else(|| panic!(#no_more_msg));
246+
let req = effect.#expect_fn();
247+
f(&req.operation);
248+
self.expect_no_effect_or_events();
249+
}
250+
251+
#[track_caller]
252+
fn #resolve_fn<F>(&mut self, f: F) -> &mut Self
253+
where
254+
F: ::core::ops::FnOnce(&#operation)
255+
-> <#operation as ::crux_core::capability::Operation>::Output,
256+
{
257+
let effect = self.effects().next()
258+
.unwrap_or_else(|| panic!(#no_more_msg));
259+
let mut req = effect.#expect_fn();
260+
let output = f(&req.operation);
261+
req.resolve(output).expect("resolve failed");
262+
self
263+
}
264+
}
265+
});
266+
267+
let test_ext = quote! {
268+
pub trait #test_ext_trait_ident<Event>
269+
where
270+
Event: ::core::marker::Send + 'static,
271+
{
272+
#(#test_ext_trait_methods)*
273+
274+
fn then_event<F>(&mut self, f: F) -> &mut Self
275+
where
276+
F: ::core::ops::FnOnce(&Event);
277+
}
278+
279+
impl<Event> #test_ext_trait_ident<Event> for ::crux_core::Command<#enum_ident, Event>
280+
where
281+
Event: ::core::marker::Send + 'static,
282+
{
283+
#(#test_ext_impl_methods)*
284+
285+
#[track_caller]
286+
fn then_event<F>(&mut self, f: F) -> &mut Self
287+
where
288+
F: ::core::ops::FnOnce(&Event),
289+
{
290+
let ev = self.events().next()
291+
.unwrap_or_else(|| panic!("expected an event but got none"));
292+
f(&ev);
293+
self
294+
}
295+
}
296+
};
297+
155298
let type_gen = match typegen_kind {
156299
TypegenKind::Serde => {
157300
let effect_gen = effects.map(|effect| {
@@ -252,6 +395,8 @@ pub fn effect_impl(args: Option<Ident>, input: ItemEnum) -> TokenStream {
252395

253396
#(#filters)*
254397

398+
#test_ext
399+
255400
#type_gen
256401

257402
}

0 commit comments

Comments
 (0)