use std::sync::mpsc::{Receiver, Sender};
use std::time::{Duration, Instant};
use eframe::egui;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use super::{FAILED, MAX_HTML, Renderer, Reply, Request, TOO_LARGE};
use crate::gui::App;
use crate::gui::test_support::{Fixture, Wires, message};
pub(crate) const NEWSLETTER: &str = include_str!("../../../tests/fixtures/newsletter.html");
const DOT: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
pub(crate) fn raw(html: &str) -> Vec<u8> {
format!(
"From: news@example.org\r\nSubject: news\r\nMIME-Version: 1.0\r\n\
Content-Type: multipart/related; boundary=\"b\"\r\n\r\n\
--b\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{html}\r\n\
--b\r\nContent-Type: image/png\r\nContent-ID: <dot@example.com>\r\nContent-Transfer-Encoding: base64\r\n\r\n{DOT}\r\n--b--\r\n"
)
.into_bytes()
}
pub(crate) fn add_html(fx: &Fixture, uid: u32, html: &str, text: &str) {
let mut m = message("INBOX", uid, &format!("news {uid}"));
m.body_text = Some(text.into());
fx.add("work", m);
fx.store("work")
.set_raw("INBOX", uid, &raw(html), text)
.unwrap();
}
pub(crate) fn wait_for(harness: &mut Harness<'static, App>, done: impl Fn(&App) -> bool) {
let deadline = Instant::now() + Duration::from_secs(30);
while !done(harness.state()) {
assert!(
Instant::now() < deadline,
"timed out waiting for the renderer"
);
std::thread::sleep(Duration::from_millis(5));
harness.step();
}
harness.run();
}
pub(crate) fn painted(app: &App) -> bool {
app.body
.as_ref()
.and_then(|b| b.html_view.as_ref())
.is_some_and(|v| {
v.page.is_some()
&& !v.requested.is_empty()
&& v.requested.iter().all(|s| v.strips.contains_key(s))
})
}
fn press(harness: &mut Harness<'static, App>, text: &str) {
harness.event(egui::Event::Text(text.into()));
harness.run_steps(2);
}
fn opened(harness: &Harness<'static, App>) -> Vec<String> {
harness
.output()
.platform_output
.commands
.iter()
.filter_map(|c| match c {
egui::OutputCommand::OpenUrl(open) => Some(open.url.clone()),
_ => None,
})
.collect()
}
fn click_page(harness: &mut Harness<'static, App>) -> Option<String> {
let at = egui::pos2(1000.0, 600.0);
harness.event(egui::Event::PointerMoved(at));
wait_for(harness, |app| {
app.body
.as_ref()
.and_then(|b| b.html_view.as_ref())
.is_some_and(|v| v.hovered_at.is_some() && v.hover.is_some())
});
let hover = harness
.state()
.body
.as_ref()?
.html_view
.as_ref()?
.hover
.clone();
for pressed in [true, false] {
harness.event(egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
});
}
harness.step();
hover
}
fn page_link(href: &str) -> String {
format!("<a href=\"{href}\" style=\"display:block;height:3000px\">link</a>")
}
#[test]
fn html_mail_shows_as_html_and_v_switches_that_message_to_text() {
let fx = Fixture::new(&["work"]);
add_html(&fx, 1, "<p>older</p>", "older as text");
add_html(&fx, 2, NEWSLETTER, "newer as text");
let (mut harness, _wires) = fx.harness();
wait_for(&mut harness, painted);
assert!(harness.query_by_label("newer as text").is_none());
assert!(
harness
.query_by_label("Remote content not loaded.")
.is_some()
);
press(&mut harness, "v");
assert!(harness.query_by_label("newer as text").is_some());
press(&mut harness, "v");
assert!(harness.query_by_label("newer as text").is_none());
press(&mut harness, "v");
press(&mut harness, "j");
wait_for(&mut harness, painted);
assert!(harness.query_by_label("older as text").is_none());
assert!(
harness
.query_by_label("Remote content not loaded.")
.is_none()
);
}
#[test]
fn v_on_plain_text_mail_changes_nothing() {
let fx = Fixture::new(&["work"]);
fx.add("work", message("INBOX", 1, "plain"));
let (mut harness, _wires) = fx.harness();
harness.run();
press(&mut harness, "v");
assert!(!harness.state().body.as_ref().unwrap().show_text);
assert!(harness.query_by_label("Body of 1").is_some());
assert!(harness.state().renderer.is_none());
}
#[test]
fn an_https_link_opens_and_shows_its_target() {
let fx = Fixture::new(&["work"]);
add_html(&fx, 1, &page_link("https://example.com/x"), "link");
let (mut harness, _wires) = fx.harness();
wait_for(&mut harness, painted);
let hover = click_page(&mut harness);
assert_eq!(hover.as_deref(), Some("https://example.com/x"));
assert_eq!(opened(&harness), ["https://example.com/x"]);
}
#[test]
fn a_javascript_link_does_nothing() {
let fx = Fixture::new(&["work"]);
add_html(&fx, 1, &page_link("javascript:alert(1)"), "link");
let (mut harness, _wires) = fx.harness();
wait_for(&mut harness, painted);
assert_eq!(
click_page(&mut harness).as_deref(),
Some("javascript:alert(1)")
);
assert!(opened(&harness).is_empty());
}
#[test]
fn html_over_the_limit_shows_text_with_a_note() {
let fx = Fixture::new(&["work"]);
let huge = format!("<p>{}</p>", "x".repeat(MAX_HTML));
add_html(&fx, 1, &huge, "the text");
let (mut harness, _wires) = fx.harness();
harness.run();
assert!(harness.query_by_label(TOO_LARGE).is_some());
assert!(harness.query_by_label("the text").is_some());
assert!(harness.state().renderer.is_none());
}
struct Faked {
harness: Harness<'static, App>,
jobs: Receiver<Request>,
replies: Sender<Reply>,
generation: u64,
_wires: Wires,
}
fn faked(fx: &Fixture) -> Faked {
let (mut harness, wires) = fx.harness();
let (renderer, jobs, replies) = Renderer::fake(egui::Context::default());
let app = harness.state_mut();
app.renderer = Some(renderer);
if let Some(body) = app.body.as_mut() {
body.html_view = None;
}
harness.run();
let generation = jobs
.try_iter()
.find_map(|job| match job {
Request::Load(load) => Some(load.generation),
_ => None,
})
.expect("a layout was asked for");
Faked {
harness,
jobs,
replies,
generation,
_wires: wires,
}
}
fn page(harness: &Harness<'static, App>) -> Option<egui::Vec2> {
harness.state().body.as_ref()?.html_view.as_ref()?.page
}
#[test]
fn a_render_failure_shows_text_with_a_note() {
let fx = Fixture::new(&["work"]);
add_html(&fx, 1, "<p>x</p>", "fallback text");
let Faked {
mut harness,
replies,
generation,
..
} = faked(&fx);
replies.send(Reply::Failed { generation }).unwrap();
harness.run();
assert!(harness.query_by_label(FAILED).is_some());
assert!(harness.query_by_label("fallback text").is_some());
}
#[test]
fn a_reply_for_an_older_layout_is_dropped() {
let fx = Fixture::new(&["work"]);
add_html(&fx, 1, "<p>x</p>", "text");
let Faked {
mut harness,
replies,
generation,
..
} = faked(&fx);
let laid = |generation, height| Reply::Laid {
generation,
height,
remote: false,
width: 400.0,
};
replies.send(laid(generation - 1, 900.0)).unwrap();
harness.run();
assert_eq!(page(&harness), None);
replies.send(laid(generation, 300.0)).unwrap();
harness.run();
assert_eq!(page(&harness), Some(egui::vec2(400.0, 300.0)));
}
#[test]
fn a_page_over_the_height_limit_shows_text_with_a_note() {
let fx = Fixture::new(&["work"]);
add_html(&fx, 1, "<p>x</p>", "tall text");
let Faked {
mut harness,
replies,
generation,
..
} = faked(&fx);
let laid = Reply::Laid {
generation,
height: super::MAX_HEIGHT + 1.0,
remote: false,
width: 400.0,
};
replies.send(laid).unwrap();
harness.run();
assert!(harness.query_by_label(TOO_LARGE).is_some());
assert!(harness.query_by_label("tall text").is_some());
}
#[test]
fn a_new_width_lays_out_again_once_it_held_still() {
let fx = Fixture::new(&["work"]);
add_html(&fx, 1, "<p>x</p>", "text");
let Faked {
mut harness,
jobs,
generation: first,
..
} = faked(&fx);
harness.set_size(egui::vec2(1180.0, 800.0));
harness.step();
assert!(jobs.try_iter().all(|job| !matches!(job, Request::Load(_))));
let start = harness
.state()
.body
.as_ref()
.unwrap()
.html_view
.as_ref()
.unwrap()
.wanted;
assert!(start.is_some(), "the new width is waiting to settle");
for _ in 0..15 {
harness.step();
}
let loads: Vec<u64> = jobs
.try_iter()
.filter_map(|job| match job {
Request::Load(load) => Some(load.generation),
_ => None,
})
.collect();
assert_eq!(loads, [first + 1]);
}