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