Files
ElyOffice/crates/ely-lok/src/view/tests/input.rs
T
雷电芽衣 72bec20a60 R9.3, R9.4: AutoFilter without the header query (GitHub #4); paste on Windows
Patch 0053: under LOK, Calc takes the first row as header instead of
asking asynchronously; the worker closed the query and crashed.
Patch 0049 includes doc.hxx (gcc on Linux did not build it).
arboard read a missing HTML clipboard format as an error on Windows;
the clipboard checks the format first, and paste errors show the
whole chain. ElyTable tests compare CSV and selection text the
platform's way (P3-09).
2026-10-20 02:23:17 -04:00

330 lines
12 KiB
Rust

use super::*;
use crate::client::tests::input::paragraphs;
use ely_platform::clipboard::{self, Clip};
use gpui::{InputHandler, Keystroke, TestAppContext, Window};
/// Steps the scheduler so queued input reaches LOK.
fn flush(cx: &TestAppContext) {
for _ in 0..80 {
cx.executor().tick();
}
}
/// The document's paragraphs, saved through the shared worker.
fn saved(view: &Entity<LokView>, cx: &mut TestAppContext) -> Vec<String> {
let out = tempfile::tempdir()
.expect("a temp dir")
.keep()
.join("out.fodt");
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
view.doc
.save_as(&mut worker, &crate::file_url(&out), "fodt")
.expect("the save works");
});
paragraphs(&std::fs::read_to_string(&out).expect("the save reads"))
}
fn typed(key: &str) -> Keystroke {
let mut stroke = Keystroke::parse(key).expect("a keystroke");
stroke.key_char = Some(key.to_string());
stroke
}
#[test]
fn typing_and_composing_reach_the_document() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
cx.update(|app| {
view.update(app, |view, _| {
assert!(!view.key_down(&typed("a")), "text waits for the IME");
view.commit(None, "ab");
view.compose(None, "n", None);
view.compose(None, "ni", None);
view.compose(None, "你", None);
assert_eq!(view.marked.as_ref().map(|m| m.text.as_str()), Some("你"));
view.commit(None, "你");
assert!(view.marked.is_none());
view.compose(None, "x", None);
view.compose(None, "", None);
assert!(view.marked.is_none(), "an empty composition cancels");
assert!(view.key_down(&Keystroke::parse("enter").expect("enter")));
})
});
flush(&cx);
assert_eq!(saved(&view, &mut cx), ["ab你", "Hello 世界"]);
}
pub(super) struct Blank;
impl gpui::Render for Blank {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
gpui::Empty
}
}
#[test]
fn the_platform_ime_callbacks_compose_in_utf16() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
let window = cx.add_window(|_, _| Blank);
cx.update_window(window.into(), |_, window, app| {
let bounds = Bounds::default();
let mut ime = gpui::ElementInputHandler::new(bounds, view.clone());
ime.replace_and_mark_text_in_range(None, "nihao", Some(2..2), window, app);
let selected = ime
.selected_text_range(false, window, app)
.expect("a selection");
assert_eq!(selected.range, 2..2, "the IME caret sits inside");
let mut adjusted = None;
let head = ime.text_for_range(0..2, &mut adjusted, window, app);
assert_eq!(head.as_deref(), Some("ni"));
assert_eq!(adjusted, Some(0..2));
assert_eq!(ime.marked_text_range(window, app), Some(0..5));
ime.replace_and_mark_text_in_range(Some(0..2), "你", Some(1..1), window, app);
let marked = view.read(app).marked.as_ref().map(|m| m.text.clone());
assert_eq!(marked.as_deref(), Some("你hao"));
let selected = ime.selected_text_range(false, window, app).map(|s| s.range);
assert_eq!(selected, Some(1..1));
ime.replace_text_in_range(Some(1..4), "好", window, app);
assert_eq!(
ime.marked_text_range(window, app),
None,
"the commit ends it"
);
ime.replace_and_mark_text_in_range(None, "x", None, window, app);
ime.unmark_text(window, app);
})
.expect("the window lives");
flush(&cx);
assert_eq!(saved(&view, &mut cx), ["你好xHello 世界"]);
}
#[test]
fn a_cut_keeps_the_text_when_the_clipboard_fails() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, shared) = cx.update(|app| live_view(app, &install));
let doc = cx.update(|app| view.read(app).doc.clone());
{
let mut worker = shared.lock().expect("the worker lock");
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
let failed = crate::view::clip::copy_out(&mut worker, &doc, true, |_| {
Err(std::io::Error::other("the clipboard refused"))
});
assert!(failed.is_err(), "the write failure surfaces");
assert!(
!worker.copy(&doc).expect("copy runs").is_empty(),
"the selection stays"
);
}
assert_eq!(saved(&view, &mut cx), ["Hello 世界"], "the text stays");
}
#[test]
fn a_drag_selects_and_events_carry_the_cursor() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
cx.update(|app| {
view.update(app, |view, _| {
let none = gpui::Modifiers::default();
let y = px(view.geom.px_of(1500));
view.mouse(0, point(px(view.geom.px_of(1500)), y), 1, 1, &none);
view.mouse(2, point(px(view.geom.px_of(4000)), y), 1, 1, &none);
view.mouse(1, point(px(view.geom.px_of(4000)), y), 1, 1, &none);
})
});
let mut selected = false;
for _ in 0..40 {
flush(&cx);
selected = cx.update(|app| !view.read(app).selection.is_empty());
if selected {
break;
}
}
assert!(selected, "the drag selection reaches the view");
cx.update(|app| {
let view = view.read(app);
let bounds = view.cursor_bounds().expect("the cursor arrived");
assert!(bounds.size.height > px(0.0), "the caret has height");
});
}
#[test]
fn a_dead_document_surfaces_an_error() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, shared) = cx.update(|app| live_view(app, &install));
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
cx.update(|app| {
let errors = errors.clone();
app.subscribe(&view, move |_, error: &LokError, _| {
errors.borrow_mut().push(error.0.clone())
})
.detach();
let doc = view.read(app).doc.clone();
shared
.lock()
.expect("the worker lock")
.close_doc(doc)
.expect("the doc closes");
view.update(app, |view, _| {
view.key_down(&Keystroke::parse("enter").expect("enter"));
});
});
flush(&cx);
let errors = errors.borrow();
assert!(
errors
.iter()
.any(|error| error.contains("no such document")),
"the failure reaches subscribers: {errors:?}"
);
}
#[test]
fn the_clipboard_moves_html_tables_and_images() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
return;
}
let mut cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
let primary = if cfg!(target_os = "macos") {
"cmd-a"
} else {
"ctrl-a"
};
cx.update(|app| {
view.update(app, |view, _| {
view.key_down(&Keystroke::parse(primary).expect("select all"));
view.send(crate::view::input::Input::Copy { cut: false });
})
});
flush(&cx);
let Some(Clip::Html { html, text }) = clipboard::read().expect("the clipboard reads") else {
panic!("the copy lands as html");
};
assert!(
html.contains("Hello") && text == "Hello 世界",
"{html} / {text}"
);
clipboard::write(&Clip::Html {
html: "<table><tr><td>left</td><td>right</td></tr></table>".into(),
text: "left\tright".into(),
})
.expect("the table writes");
cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste)));
flush(&cx);
let text = saved(&view, &mut cx);
assert!(
text.contains(&"left".to_string()) && text.contains(&"right".to_string()),
"{text:?}"
);
// Plain text from another app, with no HTML beside it.
clipboard::write(&Clip::Text("outside words".into())).expect("the text writes");
cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste)));
flush(&cx);
let text = saved(&view, &mut cx);
assert!(text.iter().any(|line| line.contains("outside words")), "{text:?}");
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("the png encodes");
clipboard::write(&Clip::Png(png)).expect("the image writes");
cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste)));
flush(&cx);
let out = tempfile::tempdir()
.expect("a temp dir")
.keep()
.join("img.fodt");
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
view.doc
.save_as(&mut worker, &crate::file_url(&out), "fodt")
.expect("the save works");
});
let xml = std::fs::read_to_string(&out).expect("the save reads");
assert!(xml.contains("<draw:image"), "the image lands");
}
/// Two slides, each with one line of text.
const DECK: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:body><office:presentation>
<draw:page draw:name="one"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>One</text:p></draw:text-box></draw:frame></draw:page>
<draw:page draw:name="two"><draw:frame svg:x="2cm" svg:y="8cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>Two</text:p></draw:text-box></draw:frame></draw:page>
</office:presentation></office:body>
</office:document>"#;
/// Paints until tile (0, 0) is fresh, then its bytes.
fn fresh_corner(view: &Entity<LokView>, cx: &mut TestAppContext, not: Option<&[u8]>) -> Vec<u8> {
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) };
for _ in 0..400 {
let bytes = cx.update(|app| {
view.update(app, |view, _| view.prepare(bounds));
let read = view.read(app);
let entry = read.tiles.get(&(0, 0)).filter(|entry| !entry.stale)?;
let bytes = entry.image.as_bytes(0).expect("a frame").to_vec();
(Some(bytes.as_slice()) != not).then_some(bytes)
});
if let Some(bytes) = bytes {
return bytes;
}
cx.executor().tick();
}
panic!("tile (0, 0) never repainted");
}
#[test]
fn switching_slides_repaints_the_shown_tiles() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, worker) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK));
let one = fresh_corner(&view, &mut cx, None);
cx.update(|app| view.update(app, |view, _| view.show_part(1)));
let two = fresh_corner(&view, &mut cx, Some(&one));
let painted = cx.update(|app| {
let read = view.read(app);
let side = read.geom.tile_pixels();
let mut worker = worker.lock().expect("the worker lock");
read.doc.paint_tile(&mut worker, (0, 0, TILE_TWIPS, TILE_TWIPS), (side, side)).expect("paints")
});
assert_eq!(two, painted.pixels, "the corner shows slide two");
}