#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use ashlar_ui::automation::{self, Role};
use ashlar_ui::markdown::{self, Markdown, MarkdownLabels, MarkdownResponse, MarkdownStyle, MdBlock, Span};
use egui::accesskit::Role as AkRole;
use egui::epaint::Shape;
use egui::{Key, Pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
#[derive(Default)]
struct S {
text: String,
query: String,
streaming: bool,
revision: Option<u64>,
labels: Option<MarkdownLabels>,
tasks: Vec<(usize, bool)>,
opened: Vec<String>,
last: MarkdownResponse,
}
fn style() -> MarkdownStyle {
MarkdownStyle::from_visuals(&egui::Visuals::light(), 14.0)
}
fn harness(width: f32, height: f32, s: S) -> Harness<'static, S> {
let mut h = Harness::builder().with_size((width, height)).build_ui_state(
|ui, s: &mut S| {
let style = style();
let mut toggled = Vec::new();
let mut md = Markdown::new("md", &s.text, &style).find(&s.query).streaming(s.streaming).on_task(|i, c| toggled.push((i, c)));
if let Some(r) = s.revision {
md = md.revision(r);
}
if let Some(l) = &s.labels {
md = md.labels(l);
}
let r = md.show(ui);
s.tasks.extend(toggled);
if let Some(u) = &r.opened {
s.opened.push(u.clone());
}
s.last = r;
},
s,
);
automation::install(&h.ctx);
h.run();
h
}
fn text(md: &str) -> S {
S { text: md.into(), ..S::default() }
}
fn click(h: &mut Harness<'_, S>, p: Pos2) {
h.hover_at(p);
h.step();
h.drag_at(p);
h.step();
h.drop_at(p);
h.run();
}
fn galleys(h: &Harness<'_, S>) -> Vec<(Pos2, std::sync::Arc<egui::Galley>)> {
fn walk(s: &Shape, out: &mut Vec<(Pos2, std::sync::Arc<egui::Galley>)>) {
match s {
Shape::Text(t) => out.push((t.pos, t.galley.clone())),
Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = Vec::new();
for c in &h.output().shapes {
walk(&c.shape, &mut out);
}
out
}
fn painted_text(h: &Harness<'_, S>) -> String {
galleys(h).iter().map(|(_, g)| g.text().to_string()).collect::<Vec<_>>().join("|")
}
#[test]
fn links_take_focus_and_open_with_enter() {
let mut h = harness(500.0, 300.0, text("See [one](https://one.example) and [two](https://two.example).\n\nEnd."));
let one = automation::find(&h.ctx, "md.link.0").expect("first link registered");
assert_eq!((one.role, one.value.as_str()), (Role::Link, "https://one.example"));
assert!(automation::find(&h.ctx, "md.link.1").is_some());
let node = h.get_by_role_and_label(AkRole::Link, "two");
assert!(node.accesskit_node().bounding_box().is_some());
h.key_press(Key::Tab);
h.run();
h.key_press(Key::Enter);
h.run();
assert_eq!(h.state().opened, vec!["https://one.example".to_string()]);
h.key_press(Key::Tab);
h.run();
h.key_press(Key::Enter);
h.run();
assert_eq!(h.state().opened.last().map(String::as_str), Some("https://two.example"));
}
#[test]
fn a_wrapped_link_answers_on_every_row() {
let md = "[a long link text that wraps onto several rows of a narrow view](https://wrap.example)";
let mut h = harness(140.0, 300.0, text(md));
let first = automation::find(&h.ctx, "md.link.0").expect("link registered").egui_rect();
let below = Pos2::new(first.left() + 4.0, first.bottom() + first.height() * 0.5);
click(&mut h, below);
assert_eq!(h.state().opened, vec!["https://wrap.example".to_string()]);
}
#[test]
fn unsafe_and_pending_links_never_open() {
let mut h = harness(400.0, 200.0, text("[x](javascript:alert(1))"));
let r = automation::find(&h.ctx, "md.link.0").expect("registered").egui_rect();
click(&mut h, r.center());
assert!(h.state().opened.is_empty());
let h = harness(400.0, 200.0, S { text: "see [the do".into(), streaming: true, ..S::default() });
assert!(automation::find(&h.ctx, "md.link.0").is_none());
assert_eq!(painted_text(&h), "see the do");
}
#[test]
fn task_checkboxes_report_toggles() {
let mut h = harness(400.0, 200.0, text("- [ ] open\n- [x] done\n"));
let open = automation::find(&h.ctx, "md.task.0").expect("task 0 registered");
assert_eq!((open.role, open.selected), (Role::Toggle, Some(false)));
assert_eq!(automation::find(&h.ctx, "md.task.1").and_then(|e| e.selected), Some(true));
let node = h.get_by_role_and_label(AkRole::CheckBox, "open");
assert_eq!(node.accesskit_node().label().as_deref(), Some("open"));
click(&mut h, open.center());
let done = automation::find(&h.ctx, "md.task.1").unwrap();
click(&mut h, done.center());
assert_eq!(h.state().tasks, vec![(0, true), (1, false)]);
}
#[test]
fn find_highlights_and_counts_matches() {
let md = "Needle in a haystack, another needle.\n\n```\nlet needle = 1;\n```\n\n| a | b |\n|---|---|\n| NEEDLE | x |\n";
let mut h = harness(500.0, 400.0, S { text: md.into(), query: "needle".into(), ..S::default() });
assert_eq!(h.state().last.matches, 4);
let bg = style().find_bg;
let marked: usize = galleys(&h)
.iter()
.flat_map(|(_, g)| g.job.sections.iter().filter(|s| s.format.background == bg).map(|s| s.byte_range.end.0 - s.byte_range.start.0))
.sum();
assert_eq!(marked, 4 * "needle".len(), "every match is highlighted");
h.state_mut().query.clear();
h.run();
assert_eq!(h.state().last.matches, 0);
assert!(galleys(&h).iter().all(|(_, g)| g.job.sections.iter().all(|s| s.format.background != bg)));
}
#[test]
fn tables_are_sized_to_content_and_shrink_to_their_longest_word() {
let long = "words ".repeat(40);
let md = format!("| name | note |\n|---|---|\n| a | {long}|\n| b | incomprehensibilities |\n");
let h = harness(300.0, 600.0, text(&md));
let gs = galleys(&h);
let cell = gs.iter().find(|(_, g)| g.text().starts_with("words words")).expect("long cell painted");
assert!(cell.1.rows.len() > 1, "the long cell wraps");
assert!(cell.0.x + cell.1.rect.width() <= 300.0, "the table fits the width: {:?}", cell.1.rect);
let word = gs.iter().find(|(_, g)| g.text() == "incomprehensibilities").expect("word painted");
assert_eq!(word.1.rows.len(), 1, "a column is never narrower than its longest word");
let h = harness(600.0, 300.0, text("| a | b |\n|---|---|\n| 1 | 2 |\n"));
let gs = galleys(&h);
let a = gs.iter().find(|(_, g)| g.text() == "a").unwrap().0.x;
let b = gs.iter().find(|(_, g)| g.text() == "b").unwrap().0.x;
assert!(b - a < 40.0, "content-sized columns: {a} → {b}");
}
#[test]
fn tables_past_the_row_cap_say_how_many_rows_are_hidden() {
let mut md = String::from("| n |\n|---|\n");
for i in 0..(markdown::MAX_TABLE_ROWS + 100) {
md.push_str(&format!("| {i} |\n"));
}
let h = harness(300.0, 20_000.0, text(&md));
let more = automation::find(&h.ctx, "md.table.0.more").expect("footer registered");
assert_eq!(more.label, "100 more rows");
let labels = MarkdownLabels { more_rows: "+{n} rows hidden".into(), ..MarkdownLabels::default() };
let h = harness(300.0, 20_000.0, S { text: md, labels: Some(labels), ..S::default() });
assert_eq!(automation::find(&h.ctx, "md.table.0.more").unwrap().label, "+100 rows hidden");
}
#[test]
fn code_block_words_come_from_the_labels() {
let labels = MarkdownLabels { copy: "Kopieren".into(), code: "Quelltext".into(), ..MarkdownLabels::default() };
let h = harness(400.0, 200.0, S { text: "```\nx\n```\n".into(), labels: Some(labels), ..S::default() });
let text = painted_text(&h);
assert!(text.contains("Kopieren") && text.contains("Quelltext"), "{text}");
assert_eq!(automation::find(&h.ctx, "md.code.0.copy").unwrap().label, "Kopieren");
}
fn styles(blocks: &[MdBlock]) -> Vec<(String, bool, bool, bool, bool)> {
let spans: &[Span] = match blocks.last() {
Some(MdBlock::Paragraph(s) | MdBlock::Heading { spans: s, .. } | MdBlock::Item { spans: s, .. } | MdBlock::Quote(s)) => s,
other => panic!("not text: {other:?}"),
};
spans.iter().map(|s| (s.text.clone(), s.bold, s.italic, s.code, s.link.is_some())).collect()
}
#[test]
fn streaming_repair_styles_unclosed_markers_at_the_tail() {
let t = |s: &str, b, i, c, l| (s.to_string(), b, i, c, l);
assert_eq!(styles(&markdown::parse_streaming("say **hel")), vec![t("say ", false, false, false, false), t("hel", true, false, false, false)]);
assert_eq!(styles(&markdown::parse_streaming("say **")), vec![t("say ", false, false, false, false)]);
assert_eq!(
styles(&markdown::parse_streaming("run `cargo te")),
vec![t("run ", false, false, false, false), t("cargo te", false, false, true, false)]
);
assert_eq!(styles(&markdown::parse_streaming("an *ita")), vec![t("an ", false, false, false, false), t("ita", false, true, false, false)]);
assert_eq!(styles(&markdown::parse_streaming("see [do")), vec![t("see ", false, false, false, false), t("do", false, false, false, true)]);
assert_eq!(
styles(&markdown::parse_streaming("see [docs](https://ex")),
vec![t("see ", false, false, false, false), t("docs", false, false, false, true)]
);
assert_eq!(styles(&markdown::parse_streaming("- item **b")), vec![t("item ", false, false, false, false), t("b", true, false, false, false)]);
assert_eq!(markdown::parse_streaming("a **b** c\n\nd"), markdown::parse("a **b** c\n\nd"));
assert_eq!(markdown::parse_streaming("x **y\n\nz"), markdown::parse("x **y\n\nz"));
assert_eq!(styles(&markdown::parse("say **hel")), vec![t("say **hel", false, false, false, false)]);
let reply = "Use **bold**, `code`, *it* and [a link](https://example.com) here.";
for end in (1..=reply.len()).filter(|e| reply.is_char_boundary(*e)) {
let blocks = markdown::parse_streaming(reply.get(..end).unwrap());
let plain: String = styles(&blocks).iter().map(|s| s.0.as_str()).collect();
assert!(!plain.ends_with('*') && !plain.ends_with('`') && !plain.contains('['), "{end}: {plain:?}");
}
}
#[test]
fn streaming_view_hides_markers() {
let mut h = harness(400.0, 200.0, S { text: "say **hel".into(), streaming: true, ..S::default() });
assert_eq!(painted_text(&h), "say hel");
h.state_mut().text.push_str("lo** world");
h.run();
assert_eq!(painted_text(&h), "say hello world");
h.state_mut().streaming = false;
h.state_mut().text = "tail **".into();
h.run();
assert_eq!(painted_text(&h), "tail **");
}
#[test]
fn an_unchanged_revision_skips_the_text() {
let mut h = harness(400.0, 200.0, S { text: "alpha".into(), revision: Some(1), ..S::default() });
assert_eq!(painted_text(&h), "alpha");
h.state_mut().text = "gamma".into();
h.run();
assert_eq!(painted_text(&h), "alpha");
h.state_mut().revision = Some(2);
h.run();
assert_eq!(painted_text(&h), "gamma");
}
#[test]
fn installed_ui_labels_reach_code_blocks() {
let mut i18n = ashlar_i18n::I18n::new("en");
ashlar_ui::labels::add_catalogs(&mut i18n);
i18n.set_locale("de");
let mut h = Harness::builder().with_size((400.0, 200.0)).build_ui(|ui| {
let style = style();
markdown::show(ui, "md", "```\nx\n```\n", &style);
});
ashlar_ui::labels::install(&h.ctx, ashlar_ui::labels::UiLabels::from_i18n(&i18n));
automation::install(&h.ctx);
h.run();
assert_eq!(automation::find(&h.ctx, "md.code.0.copy").unwrap().label, "Kopieren");
assert_eq!(ashlar_ui::labels::UiLabels::from_i18n(&i18n).markdown.copied, "Kopiert");
}