1pub use iced_test as test;
3pub use iced_test::core;
4pub use iced_test::program;
5pub use iced_test::runtime;
6pub use iced_test::runtime::futures;
7pub use iced_widget as widget;
8
9mod icon;
10mod recorder;
11
12use recorder::recorder;
13
14use crate::core::Alignment::Center;
15use crate::core::Length::Fill;
16use crate::core::alignment::Horizontal::Right;
17use crate::core::border;
18use crate::core::mouse;
19use crate::core::theme;
20use crate::core::window;
21use crate::core::{Color, Element, Font, Settings, Size, Theme};
22use crate::futures::futures::channel::mpsc;
23use crate::program::Program;
24use crate::runtime::task::{self, Task};
25use crate::test::emulator;
26use crate::test::ice;
27use crate::test::instruction;
28use crate::test::{Emulator, Ice, Instruction};
29use crate::widget::{
30 button, center, column, combo_box, container, pick_list, row, rule, scrollable, slider, space,
31 stack, text, text_editor, themer,
32};
33
34use std::ops::RangeInclusive;
35
36pub fn attach<P: Program + 'static>(program: P) -> Attach<P> {
38 Attach { program }
39}
40
41#[derive(Debug)]
43pub struct Attach<P> {
44 pub program: P,
46}
47
48impl<P> Program for Attach<P>
49where
50 P: Program + 'static,
51{
52 type State = Tester<P>;
53 type Message = Message<P>;
54 type Theme = Theme;
55 type Renderer = P::Renderer;
56 type Executor = P::Executor;
57
58 fn name() -> &'static str {
59 P::name()
60 }
61
62 fn settings(&self) -> Settings {
63 let mut settings = self.program.settings();
64 settings.fonts.push(icon::FONT.into());
65 settings
66 }
67
68 fn window(&self) -> Option<window::Settings> {
69 Some(
70 self.program
71 .window()
72 .map(|window| window::Settings {
73 size: window.size + Size::new(300.0, 80.0),
74 ..window
75 })
76 .unwrap_or_default(),
77 )
78 }
79
80 fn boot(&self) -> (Self::State, Task<Self::Message>) {
81 (Tester::new(&self.program), Task::none())
82 }
83
84 fn update(&self, state: &mut Self::State, message: Self::Message) -> Task<Self::Message> {
85 state.tick(&self.program, message.0).map(Message)
86 }
87
88 fn view<'a>(
89 &self,
90 state: &'a Self::State,
91 window: window::Id,
92 ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
93 state.view(&self.program, window).map(Message)
94 }
95
96 fn theme(&self, state: &Self::State, window: window::Id) -> Option<Theme> {
97 state
98 .theme(&self.program, window)
99 .as_ref()
100 .and_then(theme::Base::seed)
101 .map(|seed| Theme::custom("Tester", seed))
102 }
103}
104
105pub struct Tester<P: Program> {
109 viewport: Size,
110 mode: emulator::Mode,
111 presets: combo_box::State<String>,
112 preset: Option<String>,
113 instructions: Vec<Instruction>,
114 state: State<P>,
115 edit: Option<text_editor::Content<P::Renderer>>,
116}
117
118enum State<P: Program> {
119 Empty,
120 Idle {
121 state: P::State,
122 },
123 Recording {
124 emulator: Emulator<P>,
125 },
126 Asserting {
127 state: P::State,
128 window: window::Id,
129 last_interaction: Option<instruction::Interaction>,
130 },
131 Playing {
132 emulator: Emulator<P>,
133 current: usize,
134 outcome: Outcome,
135 },
136}
137
138enum Outcome {
139 Running,
140 Failed,
141 Success,
142}
143
144pub struct Message<P: Program>(Tick<P>);
146
147#[derive(Debug, Clone)]
148enum Event {
149 ViewportChanged(Size),
150 ModeSelected(emulator::Mode),
151 PresetSelected(String),
152 Record,
153 Stop,
154 Play,
155 Import,
156 Export,
157 Imported(Result<Ice, ice::ParseError>),
158 Edit,
159 Edited(text_editor::Action),
160 Confirm,
161}
162
163enum Tick<P: Program> {
164 Tester(Event),
165 Program(P::Message),
166 Emulator(emulator::Event<P>),
167 Record(instruction::Interaction),
168 Assert(instruction::Interaction),
169}
170
171impl<P: Program + 'static> Tester<P> {
172 fn new(program: &P) -> Self {
173 let (state, _) = program.boot();
174 let window = program.window().unwrap_or_default();
175
176 Self {
177 mode: emulator::Mode::default(),
178 viewport: window.size,
179 presets: combo_box::State::new(
180 program
181 .presets()
182 .iter()
183 .map(program::Preset::name)
184 .map(str::to_owned)
185 .collect(),
186 ),
187 preset: None,
188 instructions: Vec::new(),
189 state: State::Idle { state },
190 edit: None,
191 }
192 }
193
194 fn is_busy(&self) -> bool {
195 matches!(
196 self.state,
197 State::Recording { .. }
198 | State::Playing {
199 outcome: Outcome::Running,
200 ..
201 }
202 )
203 }
204
205 fn update(&mut self, program: &P, event: Event) -> Task<Tick<P>> {
206 match event {
207 Event::ViewportChanged(viewport) => {
208 self.viewport = viewport;
209
210 Task::none()
211 }
212 Event::ModeSelected(mode) => {
213 self.mode = mode;
214
215 Task::none()
216 }
217 Event::PresetSelected(preset) => {
218 self.preset = Some(preset);
219
220 let (state, _) = self
221 .preset(program)
222 .map(program::Preset::boot)
223 .unwrap_or_else(|| program.boot());
224
225 self.state = State::Idle { state };
226
227 Task::none()
228 }
229 Event::Record => {
230 self.edit = None;
231 self.instructions.clear();
232
233 let (sender, receiver) = mpsc::channel(1);
234
235 let emulator = Emulator::with_preset(
236 sender,
237 program,
238 self.mode,
239 self.viewport,
240 self.preset(program),
241 );
242
243 self.state = State::Recording { emulator };
244
245 Task::run(receiver, Tick::Emulator)
246 }
247 Event::Stop => {
248 let State::Recording { emulator } =
249 std::mem::replace(&mut self.state, State::Empty)
250 else {
251 return Task::none();
252 };
253
254 while let Some(Instruction::Interact(instruction::Interaction::Mouse(
255 instruction::Mouse::Move(_),
256 ))) = self.instructions.last()
257 {
258 let _ = self.instructions.pop();
259 }
260
261 let (state, window) = emulator.into_state();
262
263 self.state = State::Asserting {
264 state,
265 window,
266 last_interaction: None,
267 };
268
269 Task::none()
270 }
271 Event::Play => {
272 self.confirm();
273
274 let (sender, receiver) = mpsc::channel(1);
275
276 let emulator = Emulator::with_preset(
277 sender,
278 program,
279 self.mode,
280 self.viewport,
281 self.preset(program),
282 );
283
284 self.state = State::Playing {
285 emulator,
286 current: 0,
287 outcome: Outcome::Running,
288 };
289
290 Task::run(receiver, Tick::Emulator)
291 }
292 Event::Import => {
293 use std::fs;
294
295 let import = rfd::AsyncFileDialog::new()
296 .add_filter("ice", &["ice"])
297 .pick_file();
298
299 Task::future(import)
300 .and_then(|file| {
301 task::blocking(move |mut sender| {
302 let _ = sender.try_send(Ice::parse(
303 &fs::read_to_string(file.path()).unwrap_or_default(),
304 ));
305 })
306 })
307 .map(Event::Imported)
308 .map(Tick::Tester)
309 }
310 Event::Export => {
311 use std::fs;
312 use std::thread;
313
314 self.confirm();
315
316 let ice = Ice {
317 viewport: self.viewport,
318 mode: self.mode,
319 preset: self.preset.clone(),
320 instructions: self.instructions.clone(),
321 };
322
323 let export = rfd::AsyncFileDialog::new()
324 .add_filter("ice", &["ice"])
325 .save_file();
326
327 Task::future(async move {
328 let Some(file) = export.await else {
329 return;
330 };
331
332 let _ = thread::spawn(move || fs::write(file.path(), ice.to_string()));
333 })
334 .discard()
335 }
336 Event::Imported(Ok(ice)) => {
337 self.viewport = ice.viewport;
338 self.mode = ice.mode;
339 self.preset = ice.preset;
340 self.instructions = ice.instructions;
341 self.edit = None;
342
343 let (state, _) = self
344 .preset(program)
345 .map(program::Preset::boot)
346 .unwrap_or_else(|| program.boot());
347
348 self.state = State::Idle { state };
349
350 Task::none()
351 }
352 Event::Edit => {
353 if self.is_busy() {
354 return Task::none();
355 }
356
357 self.edit = Some(text_editor::Content::with_text(
358 &self
359 .instructions
360 .iter()
361 .map(Instruction::to_string)
362 .collect::<Vec<_>>()
363 .join("\n"),
364 ));
365
366 Task::none()
367 }
368 Event::Edited(action) => {
369 if let Some(edit) = &mut self.edit {
370 edit.perform(action);
371 }
372
373 Task::none()
374 }
375 Event::Confirm => {
376 self.confirm();
377
378 Task::none()
379 }
380 Event::Imported(Err(error)) => {
381 log::error!("{error}");
382
383 Task::none()
384 }
385 }
386 }
387
388 fn confirm(&mut self) {
389 let Some(edit) = &mut self.edit else {
390 return;
391 };
392
393 self.instructions = edit
394 .lines()
395 .filter(|line| !line.text.trim().is_empty())
396 .filter_map(|line| Instruction::parse(&line.text).ok())
397 .collect();
398
399 self.edit = None;
400 }
401
402 fn theme(&self, program: &P, window: window::Id) -> Option<P::Theme> {
403 match &self.state {
404 State::Empty => None,
405 State::Idle { state } => program.theme(state, window),
406 State::Recording { emulator } | State::Playing { emulator, .. } => {
407 emulator.theme(program)
408 }
409 State::Asserting { state, window, .. } => program.theme(state, *window),
410 }
411 }
412
413 fn preset<'a>(&self, program: &'a P) -> Option<&'a program::Preset<P::State, P::Message>> {
414 self.preset.as_ref().and_then(|preset| {
415 program
416 .presets()
417 .iter()
418 .find(|candidate| candidate.name() == preset)
419 })
420 }
421
422 fn tick(&mut self, program: &P, tick: Tick<P>) -> Task<Tick<P>> {
423 match tick {
424 Tick::Tester(message) => self.update(program, message),
425 Tick::Program(message) => {
426 let State::Recording { emulator } = &mut self.state else {
427 return Task::none();
428 };
429
430 emulator.update(program, message);
431
432 Task::none()
433 }
434 Tick::Emulator(event) => {
435 match &mut self.state {
436 State::Recording { emulator } => {
437 if let emulator::Event::Action(action) = event {
438 emulator.perform(program, action);
439 }
440 }
441 State::Playing {
442 emulator,
443 current,
444 outcome,
445 } => match event {
446 emulator::Event::Action(action) => {
447 emulator.perform(program, action);
448 }
449 emulator::Event::Failed(_instruction) => {
450 *outcome = Outcome::Failed;
451 }
452 emulator::Event::Ready => {
453 *current += 1;
454
455 if let Some(instruction) = self.instructions.get(*current - 1) {
456 emulator.run(program, instruction);
457 }
458
459 if *current >= self.instructions.len() {
460 *outcome = Outcome::Success;
461 }
462 }
463 },
464 State::Empty | State::Idle { .. } | State::Asserting { .. } => {}
465 }
466
467 Task::none()
468 }
469 Tick::Record(interaction) => {
470 let mut interaction = Some(interaction);
471
472 while let Some(new_interaction) = interaction.take() {
473 if let Some(Instruction::Interact(last_interaction)) = self.instructions.pop() {
474 let (merged_interaction, new_interaction) =
475 last_interaction.merge(new_interaction);
476
477 if let Some(new_interaction) = new_interaction {
478 self.instructions
479 .push(Instruction::Interact(merged_interaction));
480
481 self.instructions
482 .push(Instruction::Interact(new_interaction));
483 } else {
484 interaction = Some(merged_interaction);
485 }
486 } else {
487 self.instructions
488 .push(Instruction::Interact(new_interaction));
489 }
490 }
491
492 Task::none()
493 }
494 Tick::Assert(interaction) => {
495 let State::Asserting {
496 last_interaction, ..
497 } = &mut self.state
498 else {
499 return Task::none();
500 };
501
502 *last_interaction = if let Some(last_interaction) = last_interaction.take() {
503 let (merged, new) = last_interaction.merge(interaction);
504
505 Some(new.unwrap_or(merged))
506 } else {
507 Some(interaction)
508 };
509
510 let Some(interaction) = last_interaction.take() else {
511 return Task::none();
512 };
513
514 let instruction::Interaction::Mouse(instruction::Mouse::Click {
515 button: mouse::Button::Left,
516 target: Some(instruction::Target::Text(text)),
517 }) = interaction
518 else {
519 *last_interaction = Some(interaction);
520 return Task::none();
521 };
522
523 self.instructions
524 .push(Instruction::Expect(instruction::Expectation::Text(text)));
525
526 Task::none()
527 }
528 }
529 }
530
531 fn view<'a>(
532 &'a self,
533 program: &P,
534 window: window::Id,
535 ) -> Element<'a, Tick<P>, Theme, P::Renderer> {
536 let status = {
537 let (icon, label) = match &self.state {
538 State::Empty | State::Idle { .. } => (text(""), "Idle"),
539 State::Recording { .. } => (icon::record(), "Recording"),
540 State::Asserting { .. } => (icon::lightbulb(), "Asserting"),
541 State::Playing { outcome, .. } => match outcome {
542 Outcome::Running => (icon::play(), "Playing"),
543 Outcome::Failed => (icon::cancel(), "Failed"),
544 Outcome::Success => (icon::check(), "Success"),
545 },
546 };
547
548 container(row![icon.size(14), label].align_y(Center).spacing(8)).style(
549 |theme: &Theme| {
550 let palette = theme.palette();
551
552 container::Style {
553 text_color: Some(match &self.state {
554 State::Empty | State::Idle { .. } => palette.background.strongest.color,
555 State::Recording { .. } => palette.danger.base.color,
556 State::Asserting { .. } => palette.warning.base.color,
557 State::Playing { outcome, .. } => match outcome {
558 Outcome::Running => palette.primary.base.color,
559 Outcome::Failed => palette.danger.base.color,
560 Outcome::Success => palette.success.strong.color,
561 },
562 }),
563 ..container::Style::default()
564 }
565 },
566 )
567 };
568
569 let view = match &self.state {
570 State::Empty => Element::from(space()),
571 State::Idle { state } => program.view(state, window).map(Tick::Program),
572 State::Recording { emulator } => recorder(emulator.view(program).map(Tick::Program))
573 .on_record(Tick::Record)
574 .into(),
575 State::Asserting { state, window, .. } => {
576 recorder(program.view(state, *window).map(Tick::Program))
577 .on_record(Tick::Assert)
578 .into()
579 }
580 State::Playing { emulator, .. } => emulator.view(program).map(Tick::Program),
581 };
582
583 let viewport = container(
584 scrollable(
585 container(themer(self.theme(program, window), view))
586 .width(self.viewport.width)
587 .height(self.viewport.height),
588 )
589 .direction(scrollable::Direction::Both {
590 vertical: scrollable::Scrollbar::default(),
591 horizontal: scrollable::Scrollbar::default(),
592 }),
593 )
594 .style(|theme: &Theme| {
595 let palette = theme.palette();
596
597 container::Style {
598 border: border::width(2.0).color(match &self.state {
599 State::Empty | State::Idle { .. } => palette.background.strongest.color,
600 State::Recording { .. } => palette.danger.base.color,
601 State::Asserting { .. } => palette.warning.weak.color,
602 State::Playing { outcome, .. } => match outcome {
603 Outcome::Running => palette.primary.base.color,
604 Outcome::Failed => palette.danger.strong.color,
605 Outcome::Success => palette.success.strong.color,
606 },
607 }),
608 ..container::Style::default()
609 }
610 })
611 .padding(10);
612
613 row![
614 center(column![status, viewport].spacing(10).align_x(Right)).padding(10),
615 rule::vertical(1).style(rule::weak),
616 container(self.controls().map(Tick::Tester))
617 .width(250)
618 .padding(10)
619 .style(|theme| container::Style::default()
620 .background(theme.palette().background.weakest.color)),
621 ]
622 .into()
623 }
624
625 fn controls(&self) -> Element<'_, Event, Theme, P::Renderer> {
626 let viewport = column![
627 labeled_slider(
628 "Width",
629 100.0..=2000.0,
630 self.viewport.width,
631 |width| Event::ViewportChanged(Size {
632 width,
633 ..self.viewport
634 }),
635 |width| format!("{width:.0}"),
636 ),
637 labeled_slider(
638 "Height",
639 100.0..=2000.0,
640 self.viewport.height,
641 |height| Event::ViewportChanged(Size {
642 height,
643 ..self.viewport
644 }),
645 |height| format!("{height:.0}"),
646 ),
647 ]
648 .spacing(10);
649
650 let preset = combo_box(
651 &self.presets,
652 "Default",
653 self.preset.as_ref(),
654 Event::PresetSelected,
655 )
656 .size(14)
657 .width(Fill);
658
659 let mode = pick_list(
660 Some(self.mode),
661 emulator::Mode::ALL,
662 emulator::Mode::to_string,
663 )
664 .on_select(Event::ModeSelected)
665 .text_size(14)
666 .width(Fill);
667
668 let player = {
669 let instructions = if let Some(edit) = &self.edit {
670 text_editor(edit)
671 .size(12)
672 .height(Fill)
673 .font(Font::MONOSPACE)
674 .on_action(Event::Edited)
675 .into()
676 } else if self.instructions.is_empty() {
677 Element::from(center(
678 text("No instructions recorded yet!")
679 .size(14)
680 .font(Font::MONOSPACE)
681 .width(Fill)
682 .center(),
683 ))
684 } else {
685 scrollable(
686 column(
687 self.instructions
688 .iter()
689 .enumerate()
690 .map(|(i, instruction)| {
691 text(instruction.to_string())
692 .wrapping(text::Wrapping::None) .size(10)
694 .font(Font::MONOSPACE)
695 .style(move |theme: &Theme| text::Style {
696 color: match &self.state {
697 State::Playing {
698 current, outcome, ..
699 } => {
700 if *current == i + 1 {
701 Some(match outcome {
702 Outcome::Running => {
703 theme.palette().primary.base.color
704 }
705 Outcome::Failed => {
706 theme.palette().danger.strong.color
707 }
708 Outcome::Success => {
709 theme.palette().success.strong.color
710 }
711 })
712 } else if *current > i + 1 {
713 Some(theme.palette().success.strong.color)
714 } else {
715 None
716 }
717 }
718 _ => None,
719 },
720 })
721 .into()
722 }),
723 )
724 .spacing(5),
725 )
726 .width(Fill)
727 .height(Fill)
728 .spacing(5)
729 .into()
730 };
731
732 let control = |icon: text::Text<'static, _, _>| {
733 button(icon.size(14).width(Fill).height(Fill).center())
734 };
735
736 let play = control(icon::play()).on_press_maybe(
737 (!matches!(self.state, State::Recording { .. }) && !self.instructions.is_empty())
738 .then_some(Event::Play),
739 );
740
741 let record = if let State::Recording { .. } = &self.state {
742 control(icon::stop())
743 .on_press(Event::Stop)
744 .style(button::success)
745 } else {
746 control(icon::record())
747 .on_press_maybe((!self.is_busy()).then_some(Event::Record))
748 .style(button::danger)
749 };
750
751 let import = control(icon::folder())
752 .on_press_maybe((!self.is_busy()).then_some(Event::Import))
753 .style(button::secondary);
754
755 let export = control(icon::floppy())
756 .on_press_maybe(
757 (!matches!(self.state, State::Recording { .. })
758 && !self.instructions.is_empty())
759 .then_some(Event::Export),
760 )
761 .style(button::success);
762
763 let controls = row![import, export, play, record].height(30).spacing(10);
764
765 column![instructions, controls].spacing(10).align_x(Center)
766 };
767
768 let edit = if self.is_busy() {
769 Element::from(space::horizontal())
770 } else if self.edit.is_none() {
771 button(icon::pencil().size(14))
772 .padding(0)
773 .on_press(Event::Edit)
774 .style(button::text)
775 .into()
776 } else {
777 button(icon::check().size(14))
778 .padding(0)
779 .on_press(Event::Confirm)
780 .style(button::text)
781 .into()
782 };
783
784 column![
785 labeled("Viewport", viewport),
786 labeled("Mode", mode),
787 labeled("Preset", preset),
788 labeled_with("Instructions", edit, player)
789 ]
790 .spacing(10)
791 .into()
792 }
793}
794
795fn labeled<'a, Message, Renderer>(
796 fragment: impl text::IntoFragment<'a>,
797 content: impl Into<Element<'a, Message, Theme, Renderer>>,
798) -> Element<'a, Message, Theme, Renderer>
799where
800 Message: 'a,
801 Renderer: program::Renderer + 'a,
802{
803 column![
804 text(fragment).size(14).font(Font::MONOSPACE),
805 content.into()
806 ]
807 .spacing(5)
808 .into()
809}
810
811fn labeled_with<'a, Message, Renderer>(
812 fragment: impl text::IntoFragment<'a>,
813 control: impl Into<Element<'a, Message, Theme, Renderer>>,
814 content: impl Into<Element<'a, Message, Theme, Renderer>>,
815) -> Element<'a, Message, Theme, Renderer>
816where
817 Message: 'a,
818 Renderer: program::Renderer + 'a,
819{
820 column![
821 row![
822 text(fragment).size(14).font(Font::MONOSPACE),
823 space::horizontal(),
824 control.into()
825 ]
826 .spacing(5)
827 .align_y(Center),
828 content.into()
829 ]
830 .spacing(5)
831 .into()
832}
833
834fn labeled_slider<'a, Message, Renderer>(
835 label: impl text::IntoFragment<'a>,
836 range: RangeInclusive<f32>,
837 current: f32,
838 on_change: impl Fn(f32) -> Message + 'a,
839 to_string: impl Fn(&f32) -> String,
840) -> Element<'a, Message, Theme, Renderer>
841where
842 Message: Clone + 'a,
843 Renderer: core::text::Renderer + 'a,
844{
845 stack![
846 container(
847 slider(range, current, on_change)
848 .step(10.0)
849 .width(Fill)
850 .height(24)
851 .style(|theme: &core::Theme, status| {
852 let palette = theme.palette();
853
854 slider::Style {
855 rail: slider::Rail {
856 backgrounds: (
857 match status {
858 slider::Status::Active | slider::Status::Dragged => {
859 palette.background.strongest.color
860 }
861 slider::Status::Hovered | slider::Status::Focused => {
862 palette.background.stronger.color
863 }
864 }
865 .into(),
866 Color::TRANSPARENT.into(),
867 ),
868 width: 24.0,
869 border: border::rounded(2),
870 },
871 handle: slider::Handle {
872 shape: slider::HandleShape::Circle { radius: 0.0 },
873 background: Color::TRANSPARENT.into(),
874 border_width: 0.0,
875 border_color: Color::TRANSPARENT,
876 shadow: core::Shadow::default(),
877 },
878 }
879 })
880 )
881 .style(|theme| container::Style::default()
882 .background(theme.palette().background.weak.color)
883 .border(border::rounded(2))),
884 row![
885 text(label).size(14).style(|theme: &core::Theme| {
886 text::Style {
887 color: Some(theme.palette().background.weak.text),
888 }
889 }),
890 space::horizontal(),
891 text(to_string(¤t)).size(14)
892 ]
893 .padding([0, 10])
894 .height(Fill)
895 .align_y(Center),
896 ]
897 .into()
898}