use std::{cell::RefCell, rc::Rc}; use ely_gpui_component::theme::Theme; use ely_lok::LokError; use ely_office::panel::Panel as _; use gpui::{Entity, TestAppContext}; use super::Props; use crate::docs::Docs; use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written}; use ely_office::prelude::*; /// The panel, shown on the active tab. pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity) -> Entity { let props = cx.update(|app| { let tab = docs.read(app).active_tab().expect("a tab"); let (view, doc) = (tab.view.clone(), tab.doc.clone()); let props = docs.read(app).props.clone(); props.update(app, |props, cx| props.show(Some((&view, &doc)), cx)); props }); // The lists come from a background read. settle(cx, |app| { let props = props.read(app); !props.fonts.is_empty() && props.shown.as_ref().is_some_and(|shown| !shown.paragraph.is_empty()) }); props } fn run(cx: &mut TestAppContext, props: &Entity, id: &str, values: &[&str]) { cx.update(|app| props.update(app, |props, cx| props.run(id, values, cx))); } #[test] fn the_panel_formats_text_paragraphs_and_styles() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let Some(docs) = docs(&mut cx, dir.path()) else { eprintln!("SKIP: no LOK build"); return; }; let id = blank(&mut cx, &docs); type_into(&mut cx, &docs, "Ely panel"); let props = panel(&mut cx, &docs); cx.update(|app| { let props = props.read(app); let shown = props.shown.as_ref().expect("shown"); assert!(!props.fonts.is_empty(), "the kernel lists fonts"); assert!(shown.paragraph.iter().any(|name| name == "Heading 2")); assert!(shown.character.iter().any(|name| name == "Emphasis")); }); for (command, values) in [ ("edit.select_all", vec![]), ("format.style", vec!["Heading 2", "ParagraphStyles"]), ("format.color", vec!["16711680"]), ("format.highlight", vec!["65535"]), ("format.superscript", vec![]), ("paragraph.tab", vec!["-1", "1134", "false"]), ("list.bullets", vec![]), ] { run(&mut cx, &props, command, &values); } cx.update(|app| props.update(app, |props, cx| props.create_style("Ely Note", "2", cx))); settle(&cx, |app| { let props = props.read(app); let shown = props.shown.as_ref().expect("shown"); !props.reload && shown.paragraph.iter().any(|name| name == "Ely Note") }); run(&mut cx, &props, "paragraph.right", &[]); run(&mut cx, &props, "style.update", &["Ely Note", "2"]); run( &mut cx, &props, "format.style", &["Emphasis", "CharacterStyles"], ); settle(&cx, |app| { let tab = docs.read(app).active_tab().expect("a tab"); let view = tab.view.read(app); view.settled() && view.tabs().len() == 1 && view.state(".uno:FontColor") == Some("16711680") }); // The shown stop, removed by its number. cx.update(|app| ely_office::panel::send(&props, "paragraph.tab", &["0", "0", "true"], app)); settle(&cx, |app| { let view = docs.read(app).active_tab().expect("a tab").view.read(app); view.settled() && view.tabs().is_empty() }); run(&mut cx, &props, "paragraph.tab", &["-1", "1134", "false"]); let target = dir.path().join("panel.docx"); with_window(&mut cx, &docs, |docs, window, cx| { docs.write_as(id, target.clone(), None, window, cx) }); written(&cx, &target, None); let text = text_of(&mut cx, &docs, &target); for mark in [ "fo:color=\"#ff0000\"", "fo:background-color=\"#00ffff\"", "style:text-position=\"super", "style:tab-stop", "").next()) .expect("the style is written"); assert!( note.contains("fo:text-align=\"right\""), "the update reached the style" ); } #[test] fn bad_values_report_and_the_panel_follows_the_tab() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let Some(docs) = docs(&mut cx, dir.path()) else { eprintln!("SKIP: no LOK build"); return; }; blank(&mut cx, &docs); let props = panel(&mut cx, &docs); let errors = Rc::new(RefCell::new(Vec::new())); cx.update(|app| { let errors = errors.clone(); app.subscribe(&props, move |_, error: &LokError, _| { errors.borrow_mut().push(error.0.clone()) }) .detach() }); run(&mut cx, &props, "format.size", &["0"]); assert_eq!(errors.borrow().len(), 1, "an out-of-range size reports"); assert!(errors.borrow()[0].starts_with("format.size")); let first = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.entity_id()); blank(&mut cx, &docs); let props = panel(&mut cx, &docs); let second = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.entity_id()); assert_ne!(first, second); cx.update(|app| { let shown = props .read(app) .shown .as_ref() .map(|shown| shown.view.entity_id()); assert_eq!(shown, Some(second), "the panel moves to the new tab"); props.update(app, |props, cx| props.show(None, cx)); assert!(props.read(app).shown.is_none(), "no tab, no panel"); }); } /// Posts `keys` (LOK key codes) straight to the active document. fn keys(cx: &mut TestAppContext, docs: &Entity, keys: &[i32]) { cx.update(|app| { let docs = docs.read(app); let doc = docs.active_tab().expect("a tab").doc.clone(); let mut worker = docs.core.worker.lock().expect("the worker lock"); for &code in keys { worker.post_key(&doc, 0, code).expect("the key posts"); } }); } /// A text style's definition in a flat ODF file. fn text_style<'a>(text: &'a str, name: &str) -> &'a str { let head = format!("style:name=\"{name}\""); text.split(head.as_str()) .nth(1) .and_then(|rest| rest.split("").next()) .unwrap_or_else(|| panic!("{name} is defined")) } #[test] fn update_follows_the_character_style_under_the_cursor() { const HOME: i32 = 1028; const END: i32 = 1029; const LEFT: i32 = 1026; const RIGHT: i32 = 1027; const SHIFT: i32 = 0x1000; let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let Some(docs) = docs(&mut cx, dir.path()) else { eprintln!("SKIP: no LOK build"); return; }; let id = blank(&mut cx, &docs); type_into(&mut cx, &docs, "Alpha Beta"); let props = panel(&mut cx, &docs); run(&mut cx, &props, "edit.select_all", &[]); run( &mut cx, &props, "format.style", &["Strong Emphasis", "CharacterStyles"], ); settled(&cx, &docs); keys( &mut cx, &docs, &[ HOME, RIGHT | SHIFT, RIGHT | SHIFT, RIGHT | SHIFT, RIGHT | SHIFT, RIGHT | SHIFT, ], ); run( &mut cx, &props, "format.style", &["Emphasis", "CharacterStyles"], ); settled(&cx, &docs); keys( &mut cx, &docs, &[END, LEFT | SHIFT, LEFT | SHIFT, LEFT | SHIFT, LEFT | SHIFT], ); let current = |app: &gpui::App| props.read(app).state(".uno:CharStyle", app); settle(&cx, |app| { current(app).as_deref() == Some("Strong Emphasis") }); cx.update(|app| { props.update(app, |props, cx| props.show(None, cx)); assert_eq!(current(app), None, "a hidden panel shows nothing"); }); let props = panel(&mut cx, &docs); let target = cx.update(|app| props.read(app).state(".uno:CharStyle", app)); assert_eq!( target.as_deref(), Some("Strong Emphasis"), "shown again, still synced" ); run(&mut cx, &props, "format.color", &["16711680"]); let target = target.expect("a reported style"); run(&mut cx, &props, "style.update", &[&target, "1"]); settled(&cx, &docs); run(&mut cx, &props, "edit.select_all", &[]); settle(&cx, |app| current(app).as_deref() == Some(ely_lok::MIXED)); let mixed = cx.update(|app| current(app)).expect("reported"); assert!( !ely_office::panel::known(&mixed), "a mixed selection has no update target" ); let path = dir.path().join("styles.docx"); with_window(&mut cx, &docs, |docs, window, cx| { docs.write_as(id, path.clone(), None, window, cx) }); written(&cx, &path, None); let text = text_of(&mut cx, &docs, &path); assert!(text_style(&text, "Strong_20_Emphasis").contains("fo:color=\"#ff0000\"")); assert!( !text_style(&text, "Emphasis").contains("#ff0000"), "Emphasis stays" ); } #[test] fn quick_spacing_edits_keep_each_other() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let Some(docs) = docs(&mut cx, dir.path()) else { eprintln!("SKIP: no LOK build"); return; }; let id = blank(&mut cx, &docs); type_into(&mut cx, &docs, "Spacing"); let props = panel(&mut cx, &docs); let spacing = |app: &gpui::App| props.read(app).state(super::paragraph::SPACING, app); settle(&cx, |app| spacing(app).is_some()); let set = |cx: &mut TestAppContext, below: bool, points: f64| { let twips = (points * 20.0).round() as i32; let pair = (super::paragraph::SPACING, "paragraph.spacing"); cx.update(|app| props.update(app, |props, cx| props.set_pair(pair, below, twips, cx))) }; set(&mut cx, false, 6.0); set(&mut cx, true, 10.0); settle(&cx, |app| spacing(app).as_deref() == Some("212,353")); set(&mut cx, false, 8.0); set(&mut cx, true, 12.0); settle(&cx, |app| spacing(app).as_deref() == Some("282,423")); settled(&cx, &docs); let path = dir.path().join("spacing.docx"); with_window(&mut cx, &docs, |docs, window, cx| { docs.write_as(id, path.clone(), None, window, cx) }); written(&cx, &path, None); let text = text_of(&mut cx, &docs, &path); assert!( text.contains("fo:margin-top=\"0.111in\" fo:margin-bottom=\"0.1665in\""), "both quick edits land" ); } /// A window root that shows the panel. struct Shown(Entity); impl gpui::Render for Shown { fn render( &mut self, _: &mut gpui::Window, _: &mut gpui::Context, ) -> impl gpui::IntoElement { use gpui::{ParentElement as _, Styled as _}; gpui::div().size_full().child(self.0.clone()) } } /// The top of the element marked `selector`. fn top(cx: &mut gpui::VisualTestContext, selector: &'static str) -> Option { // Past reduced motion's 1ms folds. for _ in 0..3 { std::thread::sleep(std::time::Duration::from_millis(2)); cx.update(|window, _| window.refresh()); cx.run_until_parked(); } cx.debug_bounds(selector).map(|bounds| bounds.origin.y) } #[test] fn in_a_table_the_table_group_comes_first_and_folds_stay_put() { const DOWN: i32 = 1024; const UP: i32 = 1025; let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let Some(docs) = docs(&mut cx, dir.path()) else { eprintln!("SKIP: no LOK build"); return; }; cx.update(|cx| { ely_gpui_component::init_for_tests(cx); Theme::update(cx, |theme| theme.reduced_motion = true); }); blank(&mut cx, &docs); type_into(&mut cx, &docs, "Table"); let props = panel(&mut cx, &docs); let window = cx.add_window(|_, _| Shown(props.clone())); let mut shown = gpui::VisualTestContext::from_window(window.into(), &cx); assert_eq!( top(&mut shown, "doc-props-table"), None, "no table, no group" ); let header = gpui::point(gpui::px(10.0), gpui::px(12.0)); shown.simulate_click(header, gpui::Modifiers::none()); assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds"); shown.simulate_click(header, gpui::Modifiers::none()); assert!(top(&mut shown, "doc-props-text").is_some(), "Text opens"); with_window(&mut cx, &docs, |docs, _, cx| { docs.insert_table(3.0, 2.0, cx) }); let in_table = |app: &gpui::App| props.read(app).state(".uno:DeleteTable", app); settle(&cx, |app| in_table(app).as_deref() == Some("enabled")); let table = top(&mut shown, "doc-props-table").expect("the table group"); let text = top(&mut shown, "doc-props-text").expect("the text group"); assert!(table < text, "the table group leads: {table:?} vs {text:?}"); keys(&mut cx, &docs, &[UP]); settle(&cx, |app| in_table(app).as_deref() != Some("enabled")); shown.simulate_click(header, gpui::Modifiers::none()); assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds again"); keys(&mut cx, &docs, &[DOWN]); settle(&cx, |app| in_table(app).as_deref() == Some("enabled")); assert!(top(&mut shown, "doc-props-table").is_some(), "Table opens"); assert_eq!(top(&mut shown, "doc-props-text"), None, "Text stays folded"); keys(&mut cx, &docs, &[UP]); settle(&cx, |app| in_table(app).as_deref() != Some("enabled")); assert_eq!( top(&mut shown, "doc-props-text"), None, "and after the table" ); }