Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+398
View File
@@ -0,0 +1,398 @@
//! Transitions, effects and their previews from the deck (C10.4).
use ely_lok::show::{Part, Transition};
use ely_office::prelude::*;
use gpui::{AppContext as _, TestAppContext};
use super::objects::{act, blank};
use super::{settle, update};
use crate::preview::Playing;
#[test]
fn a_transition_and_an_effect_land_and_preview() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let motion = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
};
settle(&cx, |app| motion(app).is_some());
let fade = Transition {
preset: "fade-smoothly".into(),
duration: 0.5,
auto: false,
time: 0.0,
};
let json = ely_lok::show::transition_json(&fade, false);
update(&mut cx, &deck, |deck, cx| {
deck.design_command("slide.transition", &[&json], cx)
});
settle(&cx, |app| motion(app).is_some_and(|(t, _)| t == fade));
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
let add = serde_json::json!({ "preset": "ooo-entrance-fly-in" }).to_string();
update(&mut cx, &deck, |deck, cx| {
deck.design_command("animation.add", &[&add], cx)
});
settle(&cx, |app| {
motion(app).is_some_and(|(_, effects)| effects.len() == 1)
});
let hash = cx.update(|app| motion(app).expect("read").1[0].hash.clone());
update(&mut cx, &deck, |deck, cx| deck.preview_effects(cx));
settle(&cx, |app| deck.read(app).preview.is_some());
let moving =
cx.update(
|app| match &deck.read(app).preview.as_ref().expect("playing").playing {
Playing::Effects { layers, .. } => layers
.iter()
.filter(|(part, _)| matches!(part, Part::Moving { hash: h, .. } if *h == hash))
.count(),
Playing::Transition { .. } => 0,
},
);
assert_eq!(moving, 1, "the shape plays as its own layer");
update(&mut cx, &deck, |deck, cx| deck.stop_preview(cx));
update(&mut cx, &deck, |deck, cx| deck.preview_transition(cx));
settle(&cx, |app| deck.read(app).preview.is_some());
let first = cx.update(
|app| match &deck.read(app).preview.as_ref().expect("playing").playing {
Playing::Transition { from, preset, .. } => (from.is_none(), preset.clone()),
Playing::Effects { .. } => (false, String::new()),
},
);
assert_eq!(
first,
(true, "fade-smoothly".to_string()),
"the first slide comes from black"
);
}
#[test]
fn inserted_media_reads_back_and_is_picked() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/media/clip.mp4");
let url = ely_lok::file_url(&clip);
update(&mut cx, &deck, |deck, cx| {
let tab = deck.core.active;
deck.run_in(tab, "media.insert", &[&url, "false", "8000", "4500"], cx);
deck.force_reread(tab.expect("a tab"), cx);
});
let media = |app: &gpui::App| {
deck.read(app)
.active_tab()
.map(|tab| tab.extra.media.clone())
.unwrap_or_default()
};
settle(&cx, |app| media(app).len() == 1);
let index = cx.update(|app| media(app)[0].index).to_string();
act(&mut cx, &deck, ("object.select", &[&index, "false"]), |o| {
o.selection.is_some()
});
let picked = cx.update(|app| deck.read(app).picked_media());
assert!(picked.is_some_and(|(_, media)| !media.missing && !media.link));
}
#[test]
fn a_stop_cancels_a_preview_on_its_way_and_a_slide_change_ends_one() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let ready = |app: &gpui::App| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.motion.is_some() && tab.extra.history.is_some())
};
settle(&cx, ready);
update(&mut cx, &deck, |deck, cx| {
deck.preview_transition(cx);
deck.stop_preview(cx);
});
for _ in 0..100 {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
cx.run_until_parked();
}
assert!(
cx.update(|app| deck.read(app).preview.is_none()),
"the stopped read never plays"
);
update(&mut cx, &deck, |deck, cx| deck.preview_transition(cx));
settle(&cx, |app| deck.read(app).preview.is_some());
update(&mut cx, &deck, |deck, cx| {
let tab = deck.core.active;
deck.run_in(tab, "slide.insert", &[], cx);
deck.force_reread(tab.expect("a tab"), cx);
});
settle(&cx, |app| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.slides.current == 1)
});
update(&mut cx, &deck, |deck, cx| deck.drop_stale_preview(cx));
assert!(
cx.update(|app| deck.read(app).preview.is_none()),
"another slide ends it"
);
}
#[test]
fn typing_ends_a_preview_before_any_undo_step() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(&mut cx, &deck, ("object.select", &["0", "false"]), |o| {
o.selection.is_some()
});
let view = cx.update(|app| deck.read(app).active_tab().expect("a tab").view.clone());
let typed = |cx: &mut TestAppContext, text: &str| {
let before = cx.update(|app| view.read(app).edited());
cx.update(|app| view.update(app, |view, _| view.insert(text)));
settle(cx, |app| {
view.read(app).edited() > before && view.read(app).settled()
});
};
typed(&mut cx, "A");
let ready = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|tab| tab.extra.motion.is_some() && tab.extra.history.is_some())
};
settle(&cx, ready);
update(&mut cx, &deck, |deck, cx| deck.preview_transition(cx));
settle(&cx, |app| deck.read(app).preview.is_some());
let undo = |app: &mut gpui::App| deck.read(app).active_tab()?.extra.history.clone();
let steps = cx.update(undo);
typed(&mut cx, "B");
assert_eq!(cx.update(undo), steps, "no undo step yet");
update(&mut cx, &deck, |deck, cx| deck.drop_stale_preview(cx));
assert!(
cx.update(|app| deck.read(app).preview.is_none()),
"the typed text ends it"
);
}
#[test]
fn enter_then_blur_sends_one_duration() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let motion = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
};
settle(&cx, |app| motion(app).is_some());
let panel = cx.update(|app| {
let weak = deck.downgrade();
app.new(|cx| crate::design::Panel::new(weak, cx))
});
let now = cx.update(|app| motion(app).expect("read").0);
let send = crate::motion::dedupe(panel, "transition.duration", now.duration, |deck, n, cx| {
deck.send_transition(|t| t.duration = n, false, cx)
});
let steps = super::undo_steps(&mut cx, &deck);
let handle = cx
.update(|app| deck.read(app).core.window)
.expect("a window");
for _ in 0..2 {
cx.update_window(handle, |_, window, app| send(3.5, window, app))
.expect("the window lives");
}
settle(&cx, |app| {
motion(app).is_some_and(|(t, _)| t.duration == 3.5)
});
assert_eq!(
super::undo_steps(&mut cx, &deck),
steps + 1,
"one step for Enter and blur"
);
}
#[test]
fn two_fields_sent_before_a_read_both_land() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let motion = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
};
settle(&cx, |app| motion(app).is_some());
update(&mut cx, &deck, |deck, cx| {
deck.send_transition(
|t| (t.auto, t.duration, t.time) = (true, 2.0, 4.0),
false,
cx,
)
});
settle(&cx, |app| {
motion(app).is_some_and(|(t, _)| t.auto && t.time == 4.0)
});
let panel = cx.update(|app| {
let weak = deck.downgrade();
app.new(|cx| crate::design::Panel::new(weak, cx))
});
let duration =
crate::motion::dedupe(panel.clone(), "transition.duration", 2.0, |deck, n, cx| {
deck.send_transition(|t| t.duration = n, false, cx)
});
let time = crate::motion::dedupe(panel, "transition.time", 4.0, |deck, n, cx| {
deck.send_transition(|t| t.time = n, false, cx)
});
let handle = cx
.update(|app| deck.read(app).core.window)
.expect("a window");
cx.update_window(handle, |_, window, app| {
duration(3.5, window, app);
time(7.0, window, app);
})
.expect("the window lives");
settle(&cx, |app| {
let asked = deck
.read(app)
.active_tab()
.map(|t| t.extra.transition_asked.is_none());
asked == Some(true) && motion(app).is_some_and(|(t, _)| t.time == 7.0)
});
let read = cx.update(|app| motion(app).expect("read").0);
assert_eq!((read.duration, read.time), (3.5, 7.0));
}
#[test]
fn an_undone_duration_is_not_sent_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let motion = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
};
let asked = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|tab| tab.extra.transition_asked.is_some())
};
settle(&cx, |app| motion(app).is_some());
update(&mut cx, &deck, |deck, cx| {
deck.send_transition(
|t| (t.auto, t.duration, t.time) = (true, 2.0, 4.0),
false,
cx,
)
});
settle(&cx, |app| {
!asked(app) && motion(app).is_some_and(|(t, _)| t.time == 4.0)
});
update(&mut cx, &deck, |deck, cx| {
deck.send_transition(|t| t.duration = 3.5, false, cx);
let tab = deck.core.active;
deck.run_in(tab, "edit.undo", &[], cx);
deck.force_reread(tab.expect("a tab"), cx);
});
settle(&cx, |app| !asked(app));
update(&mut cx, &deck, |deck, cx| {
deck.send_transition(|t| t.time = 7.0, false, cx)
});
settle(&cx, |app| {
!asked(app) && motion(app).is_some_and(|(t, _)| t.time == 7.0)
});
let read = cx.update(|app| motion(app).expect("read").0);
assert_eq!((read.duration, read.time), (2.0, 7.0));
}
#[test]
fn two_effects_take_the_same_value_before_a_read() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let effects = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.motion.clone())
.map(|(_, effects)| effects)
.unwrap_or_default()
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
let add = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string();
for count in 1..=2 {
update(&mut cx, &deck, |deck, cx| {
deck.design_command("animation.add", &[&add], cx)
});
settle(&cx, |app| effects(app).len() == count);
}
for at in 0..2 {
update(&mut cx, &deck, |deck, cx| {
deck.send_effect(at, serde_json::json!({ "duration": 2.0 }), cx)
});
}
settle(&cx, |app| effects(app).iter().all(|e| e.duration == 2.0));
let panel = cx.update(|app| {
let weak = deck.downgrade();
app.new(|cx| crate::design::Panel::new(weak, cx))
});
let (slide, listed) = cx.update(|app| {
let tab = deck.read(app).active_tab().expect("a tab");
let slide = tab.extra.slides.list[tab.extra.slides.current].name.clone();
(slide, effects(app))
});
let handle = cx
.update(|app| deck.read(app).core.window)
.expect("a window");
let steps = super::undo_steps(&mut cx, &deck);
cx.update_window(handle, |_, window, app| {
for (at, effect) in listed.iter().enumerate() {
let key = crate::motion::effect_key("duration", &slide, (at, effect));
let send = crate::motion::dedupe(panel.clone(), key, 2.0, move |deck, n, cx| {
deck.send_effect(at, serde_json::json!({ "duration": n }), cx)
});
// Enter, then blur.
send(3.5, window, app);
send(3.5, window, app);
}
})
.expect("the window lives");
settle(&cx, |app| effects(app).iter().all(|e| e.duration == 3.5));
assert_eq!(
super::undo_steps(&mut cx, &deck),
steps + 2,
"one step each"
);
}
+293
View File
@@ -0,0 +1,293 @@
use ely_lok::show::{GuideKind, Objects, Paint, Selection};
use ely_office::prelude::*;
use gpui::{Entity, TestAppContext};
use super::{counts, deck, pictures_match, settle, undo_steps, update};
use crate::deck::Deck;
pub(super) fn objects(app: &gpui::App, deck: &Entity<Deck>) -> Option<Objects> {
deck.read(app).active_tab()?.extra.objects.clone()
}
pub(super) fn selection(app: &gpui::App, deck: &Entity<Deck>) -> Option<Selection> {
objects(app, deck)?.selection
}
/// A blank deck, its first picture painted.
pub(super) fn blank(cx: &mut TestAppContext, dir: &std::path::Path) -> Option<Entity<Deck>> {
let deck = deck(cx, dir)?;
update(cx, &deck, |deck, cx| deck.new_blank(cx));
settle(cx, |app| counts(app, &deck) == (1, 1));
Some(deck)
}
/// Runs `id` and waits until `done` holds.
pub(super) fn act(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
(id, values): (&str, &[&str]),
done: impl Fn(&Objects) -> bool,
) {
update(cx, deck, |deck, cx| deck.object_command(id, values, cx));
settle(cx, |app| {
objects(app, deck).is_some_and(|objects| done(&objects))
});
}
#[test]
fn shapes_take_place_look_and_lock_from_the_panel() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let rectangle = ("draw.shape", &["rectangle", "true"][..]);
act(&mut cx, &deck, rectangle, |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "shape")
});
let x = crate::object::twips(3000).to_string();
act(&mut cx, &deck, ("object.x", &[&x]), |o| {
o.selection
.as_ref()
.is_some_and(|s| (s.rect.0 - 3000).abs() <= 1)
});
act(&mut cx, &deck, ("object.fill_none", &["0"]), |o| {
o.selection.as_ref().is_some_and(|s| s.fill == Paint::None)
});
act(&mut cx, &deck, ("object.fill", &["255"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.fill == Paint::Color(255))
});
act(&mut cx, &deck, ("object.lock", &["true", "true"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.lock_position == Some(true))
});
let center = cx
.update(|app| selection(app, &deck))
.expect("a selection")
.rotation_center;
let twips = crate::object::twips;
let (x, y) = (twips(center.0).to_string(), twips(center.1).to_string());
act(&mut cx, &deck, ("object.lock", &["false", "false"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.lock_size == Some(false))
});
act(&mut cx, &deck, ("object.rotate", &["9000", &x, &y]), |o| {
o.selection.as_ref().is_some_and(|s| s.rotation == 9000)
});
// A panel edit repaints the slide's picture.
pictures_match(&mut cx, &deck);
}
#[test]
fn two_objects_group_align_and_ungroup() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let count = |n: usize| move |o: &Objects| o.objects.len() == n;
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
count(3),
);
let roles = cx.update(|app| {
let read = objects(app, &deck).expect("a read");
read.objects
.iter()
.map(|o| o.placeholder.clone())
.collect::<Vec<_>>()
});
assert_eq!(roles, ["title", "body", ""], "placeholders name their role");
act(
&mut cx,
&deck,
("draw.shape", &["ellipse", "true"]),
count(4),
);
update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["2".into(), "3".into()], cx)
});
settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| s.count == 2 && s.can_group)
});
act(&mut cx, &deck, ("object.align_left", &[]), |o| {
o.objects[2].rect.0 == o.objects[3].rect.0
});
act(&mut cx, &deck, ("object.group", &[]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.kind == "group" && s.can_ungroup)
});
act(&mut cx, &deck, ("object.ungroup", &[]), count(4));
}
#[test]
fn guides_add_move_and_go() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(&mut cx, &deck, ("guide.add", &["1", "5000", "0"]), |o| {
o.guides.len() == 1 && o.guides[0].kind == GuideKind::Vertical
});
act(&mut cx, &deck, ("guide.move", &["0", "7000", "0"]), |o| {
o.guides.first().is_some_and(|guide| guide.at.0 == 7000)
});
// Snapping starts on; the command flips it.
let snapped = cx.update(|app| objects(app, &deck).expect("a read").snap_guides);
act(&mut cx, &deck, ("view.snap_guides", &[]), move |o| o.snap_guides != snapped);
act(&mut cx, &deck, ("guide.delete", &["0", "true"]), |o| {
o.guides.is_empty()
});
}
#[test]
fn autofit_moves_between_its_three_rules() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let fit = |rule: &'static str| {
move |o: &Objects| o.selection.as_ref().is_some_and(|s| s.autofit == rule)
};
act(&mut cx, &deck, ("draw.text", &["true"]), |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "text")
});
let now = cx
.update(|app| selection(app, &deck))
.expect("a box")
.autofit;
assert_eq!(now, "grow", "a new text box grows with its text");
act(&mut cx, &deck, ("text.fit", &["off"]), fit("off"));
act(&mut cx, &deck, ("text.fit", &["shrink"]), fit("shrink"));
act(&mut cx, &deck, ("text.fit", &["grow"]), fit("grow"));
}
#[test]
fn a_repeated_commit_sends_once() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
let before = undo_steps(&mut cx, &deck);
let (panel, now) = cx.update(|app| {
let deck = deck.read(app);
let rect = deck.active_tab().and_then(|tab| tab.extra.objects.clone());
(
deck.panel.clone(),
rect.and_then(|o| o.selection).expect("a selection").rect.0,
)
});
// Enter, then the blur that follows it.
for _ in 0..2 {
cx.update(|app| {
crate::object::commit(
&panel,
"x",
(now + 1000, Some(now)),
move |deck, cx| {
let x = crate::object::twips(now + 1000).to_string();
deck.object_command("object.x", &[&x], cx)
},
app,
)
});
}
settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| (s.rect.0 - now - 1000).abs() <= 1)
});
assert_eq!(undo_steps(&mut cx, &deck), before + 1);
}
#[test]
fn pictures_insert_replace_and_crop() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let png = |name: &str, rgba| {
let path = dir.path().join(name);
image::RgbaImage::from_pixel(300, 200, image::Rgba(rgba))
.save(&path)
.expect("the png writes");
ely_lok::file_url(&path)
};
let (red, blue) = (
png("red.png", [255, 0, 0, 255]),
png("blue.png", [0, 0, 255, 255]),
);
let picture = |o: &Objects| o.selection.as_ref().is_some_and(|s| s.kind == "image");
act(&mut cx, &deck, ("image.insert", &[&red]), picture);
let steps = undo_steps(&mut cx, &deck);
act(&mut cx, &deck, ("image.replace", &[&blue]), |o| {
o.objects.len() == 3
});
settle(&cx, |app| counts(app, &deck) == (1, 1));
assert_eq!(
undo_steps(&mut cx, &deck),
steps + 1,
"a replace is one step"
);
// The worker confirms crop mode from the kernel's frame.
cx.update(|app| {
let deck = deck.read(app);
let doc = deck.active_tab().expect("a tab").doc.clone();
let mut worker = deck.core.worker.lock().expect("the worker lock");
worker.run(&doc, "image.crop", &[]).expect("crop mode");
});
}
#[test]
fn a_text_box_takes_character_formats() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(&mut cx, &deck, ("draw.text", &["true"]), |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "text")
});
let state = |uno: &'static str, want: &'static str| {
let deck = deck.clone();
move |app: &gpui::App| {
deck.read(app)
.active_tab()
.is_some_and(|tab| tab.view.read(app).state(uno) == Some(want))
}
};
update(&mut cx, &deck, |deck, cx| {
deck.object_command("format.bold", &[], cx)
});
settle(&cx, state(".uno:Bold", "true"));
update(&mut cx, &deck, |deck, cx| {
deck.object_command("format.size", &["28"], cx)
});
settle(&cx, state(".uno:FontHeight", "28"));
update(&mut cx, &deck, |deck, cx| {
deck.object_command("list.bullets", &[], cx)
});
settle(&cx, state(".uno:DefaultBullet", "true"));
}
+251
View File
@@ -0,0 +1,251 @@
//! PDF, pictures and handouts from the deck (C10.6).
use std::path::Path;
use ely_office::prelude::*;
use gpui::{AppContext as _, Entity, TestAppContext};
use super::objects::blank;
use super::play::three_slides;
use super::{settle, undo_steps, update};
use crate::deck::Deck;
use crate::output::{Kind, Output, Range};
fn output(cx: &mut TestAppContext, deck: &Entity<Deck>, kind: Kind, range: Range) -> Output {
cx.update(|app| {
let tab = deck.read(app).active_tab().expect("a tab");
Output {
tab: tab.id,
print: false,
kind,
range,
per: 2,
current: Some(1),
hidden: tab.extra.slides.list.iter().map(|slide| !slide.visible).collect(),
}
})
}
/// Pages of a PDF, by pdftotext's page breaks.
fn pages(pdf: &Path) -> Option<usize> {
let out = std::process::Command::new("pdftotext").arg(pdf).arg("-").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout).into_owned();
Some(text.split('\x0c').count() - 1)
}
#[test]
fn slides_and_handouts_go_out_and_the_deck_stays_put() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let steps = undo_steps(&mut cx, &deck);
let (slides, handouts) = (dir.path().join("slides.pdf"), dir.path().join("handouts.pdf"));
let asked = output(&mut cx, &deck, Kind::Slides, Range::All);
update(&mut cx, &deck, |deck, cx| deck.export_pdf(&asked, slides.clone(), cx));
let asked = output(&mut cx, &deck, Kind::Handouts, Range::Slides(1, 2));
update(&mut cx, &deck, |deck, cx| deck.export_pdf(&asked, handouts.clone(), cx));
settle(&cx, |_| slides.exists() && handouts.exists());
assert_eq!(undo_steps(&mut cx, &deck), steps, "the deck is not touched");
let (Some(slides), Some(handouts)) = (pages(&slides), pages(&handouts)) else {
eprintln!("SKIP: no pdftotext to count pages");
return;
};
assert_eq!(slides, 2, "the hidden slide stays out");
assert_eq!(handouts, 1, "two slides on one handout page");
}
#[test]
fn pictures_are_one_png_a_shown_slide_and_never_overwrite() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let folder = dir.path().join("out");
std::fs::create_dir_all(&folder).expect("a folder");
let asked = output(&mut cx, &deck, Kind::Pictures, Range::All);
let into = (folder.clone(), "deck".to_string());
update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx));
let written = |folder: &Path| {
let mut names: Vec<String> = std::fs::read_dir(folder)
.expect("reads")
.map(|entry| entry.expect("an entry").file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
};
settle(&cx, |_| written(&folder).len() == 2);
assert_eq!(written(&folder), ["deck-1.png", "deck-2.png"], "slide three is hidden");
let picture = image::open(folder.join("deck-1.png")).expect("a PNG");
assert_eq!(picture.width(), 1920);
let before = std::fs::read(folder.join("deck-1.png")).expect("reads");
let asked = output(&mut cx, &deck, Kind::Pictures, Range::Current);
let into = (folder.clone(), "deck".to_string());
update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx));
for _ in 0..50 {
cx.run_until_parked();
}
assert_eq!(std::fs::read(folder.join("deck-1.png")).expect("reads"), before, "never overwritten");
}
#[test]
fn the_check_lists_font_link_and_object_with_their_slides() {
use crate::check::Problem;
use crate::dialog::Dialog;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = super::deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/show/trouble.fodp");
let path = dir.path().join("trouble.fodp");
std::fs::copy(&fixture, &path).expect("copies");
update(&mut cx, &deck, |deck, cx| deck.open_file(path.clone(), cx));
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|tab| !tab.view.read(app).missing_fonts().is_empty())
});
update(&mut cx, &deck, |deck, cx| deck.check_show(cx));
settle(&cx, |app| matches!(deck.read(app).dialog, Some(Dialog::Check(_))));
let found = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Check(found)) => found.clone(),
_ => unreachable!("settled above"),
});
let short: Vec<(Problem, &str, Vec<usize>)> =
found.iter().map(|f| (f.problem, f.name.as_str(), f.slides.clone())).collect();
assert!(short.contains(&(Problem::Font, "Nowhere Sans", vec![0])), "{short:?}");
assert!(short.contains(&(Problem::Link, "file:///nowhere/gone.png", vec![1])), "{short:?}");
assert!(short.contains(&(Problem::Object, "Empty", vec![2])), "{short:?}");
}
#[cfg(unix)]
#[test]
fn pictures_claim_every_name_first_and_follow_no_link() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let folder = dir.path().join("out");
std::fs::create_dir_all(&folder).expect("a folder");
// The second name links to a file not there.
std::os::unix::fs::symlink("../unrelated.png", folder.join("deck-2.png")).expect("links");
let asked = output(&mut cx, &deck, Kind::Pictures, Range::All);
let into = (folder.clone(), "deck".to_string());
update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx));
for _ in 0..200 {
cx.run_until_parked();
}
assert!(!dir.path().join("unrelated.png").exists(), "the link is not followed");
assert!(!folder.join("deck-1.png").exists(), "nothing of this run stays");
}
#[test]
fn master_view_exports_slides_and_has_no_current() {
use crate::dialog::Dialog;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| deck.masters(true, cx));
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.extra.masters));
let window = cx.update(|app| deck.read(app).core.window.expect("a window"));
cx.update_window(window, |_, window, app| {
deck.update(app, |deck, cx| deck.on_export(&ely_ui::commands::Export, window, cx))
})
.expect("the window lives");
settle(&cx, |app| matches!(deck.read(app).dialog, Some(Dialog::Output(_))));
let (current, hidden) = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Output(output)) => (output.current, output.hidden.clone()),
_ => unreachable!("settled above"),
});
assert_eq!((current, hidden), (None, vec![false, false, true]));
}
#[test]
fn a_late_check_never_covers_a_newer_dialog() {
use crate::dialog::Dialog;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
// The export dialog is open before the check comes back.
let open = output(&mut cx, &deck, Kind::Slides, Range::All);
update(&mut cx, &deck, |deck, cx| {
deck.check_show(cx);
deck.dialog = Some(Dialog::Output(open));
});
for _ in 0..200 {
cx.run_until_parked();
}
assert!(cx.update(|app| matches!(deck.read(app).dialog, Some(Dialog::Output(_)))));
}
#[test]
fn a_late_check_stays_away_after_another_dialog_closes() {
use crate::dialog::Dialog;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let open = output(&mut cx, &deck, Kind::Slides, Range::All);
update(&mut cx, &deck, |deck, cx| {
deck.check_show(cx);
deck.dialog = Some(Dialog::Output(open));
cx.notify();
});
// The other dialog is cancelled before the check returns.
update(&mut cx, &deck, |deck, cx| {
deck.dialog = None;
cx.notify();
});
for _ in 0..200 {
cx.run_until_parked();
}
assert!(cx.update(|app| deck.read(app).dialog.is_none()), "the old check stays away");
}
#[test]
fn pictures_follow_the_slides_as_written_not_as_asked() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
// Asked while slide three hid; shown again before writing.
let asked = output(&mut cx, &deck, Kind::Pictures, Range::All);
assert_eq!(asked.hidden, [false, false, true]);
update(&mut cx, &deck, |deck, cx| deck.design_command("slide.show", &[], cx));
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|tab| tab.extra.slides.list.iter().all(|slide| slide.visible))
});
let folder = dir.path().join("out");
std::fs::create_dir_all(&folder).expect("a folder");
let into = (folder.clone(), "deck".to_string());
update(&mut cx, &deck, |deck, cx| deck.write_pictures(&asked, into, cx));
let count = |folder: &Path| std::fs::read_dir(folder).expect("reads").count();
settle(&cx, |_| count(&folder) == 3);
for n in 1..=3 {
let picture = image::open(folder.join(format!("deck-{n}.png"))).expect("a readable PNG");
assert_eq!(picture.width(), 1920);
}
}
+147
View File
@@ -0,0 +1,147 @@
//! The show: keys, hidden slides, black, laser (C10.5).
use ely_office::prelude::*;
use gpui::{Entity, Keystroke, TestAppContext};
use super::objects::{act, blank};
use super::{settle, update};
use crate::deck::Deck;
use crate::slideshow::{Show, steps::Place};
fn press(cx: &mut TestAppContext, show: &Entity<Show>, key: &str) -> bool {
let key = Keystroke::parse(key).expect("a key");
cx.update(|app| show.update(app, |show, cx| show.key(&key, cx)))
}
/// Keys typed into the show's own window.
pub(super) fn typed(cx: &mut TestAppContext, show: &Entity<Show>, keys: &str) {
let window = cx.update(|app| show.read(app).windows[0]);
cx.simulate_keystrokes(window, keys);
}
pub(super) fn place(cx: &mut TestAppContext, show: &Entity<Show>) -> Place {
cx.update(|app| show.read(app).place)
}
/// Slide two: click effect, notes. Three: hidden.
pub(super) fn three_slides(cx: &mut TestAppContext, deck: &Entity<Deck>) {
let slides = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.map(|tab| tab.extra.slides.list.iter().map(|s| s.visible).collect::<Vec<_>>())
};
update(cx, deck, |deck, cx| deck.design_command("slide.insert", &[], cx));
settle(cx, |app| slides(app).is_some_and(|list| list.len() == 2));
act(cx, deck, ("draw.shape", &["rectangle", "true"]), |o| {
o.objects.len() == 3
});
act(cx, deck, ("object.select", &["2", "false"]), |o| {
o.selection.is_some()
});
let add = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string();
let effects = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.and_then(|tab| tab.extra.motion.as_ref().map(|(_, e)| e.len()))
};
update(cx, deck, |deck, cx| deck.design_command("animation.add", &[&add], cx));
settle(cx, |app| effects(app) == Some(1));
update(cx, deck, |deck, cx| {
deck.design_command("notes.set", &["Say hello", "1"], cx);
deck.design_command("slide.insert", &[], cx);
});
settle(cx, |app| slides(app).is_some_and(|list| list.len() == 3));
update(cx, deck, |deck, cx| deck.design_command("slide.hide", &[], cx));
settle(cx, |app| slides(app) == Some(vec![true, true, false]));
}
#[test]
fn a_show_plays_clicks_skips_hidden_and_ends() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| deck.start_show(false, cx));
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
settle(&cx, |app| {
let show = show.read(app);
show.stack(0).is_some() && show.stack(1).is_some()
});
let windows = cx.update(|app| show.read(app).windows.len());
assert_eq!(windows, 1, "one screen: no presenter view");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 0, clicks: 0 });
typed(&mut cx, &show, "right");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 0 });
let notes = cx.update(|app| show.read(app).slides[1].notes.clone());
assert_eq!(notes, "Say hello");
press(&mut cx, &show, "space");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 1 });
press(&mut cx, &show, "right");
assert_eq!(place(&mut cx, &show), Place::End, "the hidden slide is passed");
press(&mut cx, &show, "left");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 1 });
press(&mut cx, &show, "1");
press(&mut cx, &show, "enter");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 0, clicks: 0 });
press(&mut cx, &show, "b");
press(&mut cx, &show, "l");
assert!(cx.update(|app| show.read(app).black && show.read(app).laser));
assert!(!press(&mut cx, &show, "cmd-q"), "app keys pass through");
let before = cx.update(|app| app.windows().len());
typed(&mut cx, &show, "escape");
settle(&cx, |app| deck.read(app).show.is_none());
assert_eq!(cx.update(|app| app.windows().len()), before - 1, "the show window closes");
}
#[test]
fn from_here_starts_on_the_shown_slide_even_hidden() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| deck.start_show(true, cx));
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
assert_eq!(place(&mut cx, &show), Place::Slide { part: 2, clicks: 0 });
press(&mut cx, &show, "right");
assert_eq!(place(&mut cx, &show), Place::End);
press(&mut cx, &show, "right");
settle(&cx, |app| deck.read(app).show.is_none());
}
#[test]
fn a_click_on_media_finds_it_and_elsewhere_does_not() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/media/clip.mp4");
let url = ely_lok::file_url(&clip);
update(&mut cx, &deck, |deck, cx| {
deck.design_command("media.insert", &[&url, "false", "8000", "4500"], cx)
});
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| tab.extra.media.len() == 1)
});
update(&mut cx, &deck, |deck, cx| deck.start_show(true, cx));
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
let (bounds, size) = cx.update(|app| {
let show = show.read(app);
(show.slides[0].media[0].bounds, show.slide)
});
let centre = (
(bounds.0 + bounds.2 / 2) as f32 / size.0,
(bounds.1 + bounds.3 / 2) as f32 / size.1,
);
assert_eq!(cx.update(|app| show.read(app).media_at(centre)), Some(0));
assert_eq!(cx.update(|app| show.read(app).media_at((0.01, 0.01))), None);
}
+211
View File
@@ -0,0 +1,211 @@
//! Show starts, copies, windows and timing (C10.5 R1).
use ely_office::prelude::*;
use gpui::{Entity, TestAppContext};
use super::objects::{act, blank};
use super::play::{place, three_slides, typed};
use super::{settle, update};
use crate::deck::Deck;
use crate::preview::timeline::{Look, object_look};
use crate::slideshow::{Show, start::show_window, steps::Place};
fn started(cx: &mut TestAppContext, deck: &Entity<Deck>, here: bool) -> Entity<Show> {
update(cx, deck, |deck, cx| deck.start_show(here, cx));
settle(cx, |app| deck.read(app).show.is_some());
cx.update(|app| deck.read(app).show.clone().expect("playing"))
}
fn bytes(cx: &mut TestAppContext, show: &Entity<Show>, part: usize) -> Vec<u8> {
cx.update(|app| {
let stack = show.read(app).stack(part).expect("read").clone();
stack.iter().flat_map(|(_, image)| image.as_bytes(0).expect("a frame").to_vec()).collect()
})
}
#[test]
fn both_windows_take_keys_and_a_second_start_waits() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| {
deck.start_show(false, cx);
deck.start_show(false, cx);
});
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
let before = cx.update(|app| app.windows().len());
for _ in 0..50 {
cx.run_until_parked();
}
assert_eq!(cx.update(|app| app.windows().len()), before, "one start, one window");
// A presenter window beside the audience, as on two screens.
let presenter = cx.update(|app| {
let display = app.primary_display().expect("a screen");
show_window(&show, &display, true, app).expect("opens")
});
cx.update(|app| show.update(app, |show, _| show.windows.push(presenter)));
cx.simulate_keystrokes(presenter, "right");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 0 });
typed(&mut cx, &show, "right");
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 1 });
}
#[test]
fn from_here_follows_a_slide_picked_just_now() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
update(&mut cx, &deck, |deck, cx| {
let view = deck.active_tab().expect("a tab").view.clone();
view.update(cx, |view, _| view.show_part(1));
deck.start_show(true, cx);
});
settle(&cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
assert_eq!(place(&mut cx, &show), Place::Slide { part: 1, clicks: 0 });
}
#[test]
fn edits_during_a_show_stay_out_of_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let show = started(&mut cx, &deck, true);
settle(&cx, |app| show.read(app).stack(2).is_some());
let first = bytes(&mut cx, &show, 2);
act(&mut cx, &deck, ("draw.shape", &["ellipse", "true"]), |o| !o.objects.is_empty());
typed(&mut cx, &show, "1 enter");
settle(&cx, |app| show.read(app).stack(2).is_none());
typed(&mut cx, &show, "3 enter");
settle(&cx, |app| show.read(app).stack(2).is_some());
assert_eq!(bytes(&mut cx, &show, 2), first, "the show plays its copy");
}
#[test]
fn late_layers_and_jumps_leave_no_trace() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let fade = r#"{"preset":"fade-smoothly","duration":10,"advance":"click","time":0,"all":false}"#;
update(&mut cx, &deck, |deck, cx| {
let view = deck.active_tab().expect("a tab").view.clone();
view.update(cx, |view, _| view.show_part(1));
deck.design_command("slide.transition", &[fade], cx);
});
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.and_then(|t| t.extra.motion.as_ref()).is_some_and(|(t, _)| t.duration == 10.0)
});
let show = started(&mut cx, &deck, false);
settle(&cx, |app| {
let show = show.read(app);
show.stack(0).is_some() && show.stack(1).is_some()
});
typed(&mut cx, &show, "right");
assert!(cx.update(|app| show.read(app).leaving.is_some()), "it fades in");
typed(&mut cx, &show, "2 enter");
assert!(cx.update(|app| show.read(app).leaving.is_none()), "a jump has no transition");
typed(&mut cx, &show, "3 enter 1 enter");
settle(&cx, |app| {
let show = show.read(app);
show.stack(0).is_some() && show.stack(1).is_some()
});
for _ in 0..100 {
cx.run_until_parked();
}
assert!(cx.update(|app| show.read(app).stack(2).is_none()), "slide three came late");
}
#[test]
fn a_finished_exit_stays_gone_as_the_slide_leaves() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let cover = r#"{"preset":"cover-left","duration":5,"advance":"click","time":0,"all":true}"#;
update(&mut cx, &deck, |deck, cx| deck.design_command("slide.transition", &[cover], cx));
update(&mut cx, &deck, |deck, cx| {
let view = deck.active_tab().expect("a tab").view.clone();
view.update(cx, |view, _| view.show_part(0));
});
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|t| t.extra.slides.current == 0 && t.extra.objects.is_some())
});
act(&mut cx, &deck, ("draw.shape", &["rectangle", "true"]), |o| o.objects.len() == 3);
act(&mut cx, &deck, ("object.select", &["2", "false"]), |o| {
o.selection.is_some()
});
let exit = serde_json::json!({ "preset": "ooo-exit-disappear" }).to_string();
update(&mut cx, &deck, |deck, cx| deck.design_command("animation.add", &[&exit], cx));
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.and_then(|t| t.extra.motion.as_ref()).is_some_and(|(_, e)| e.len() == 1)
});
let show = started(&mut cx, &deck, false);
settle(&cx, |app| show.read(app).stack(0).is_some());
typed(&mut cx, &show, "right");
std::thread::sleep(std::time::Duration::from_millis(300));
typed(&mut cx, &show, "right");
let (look, hash) = cx.update(|app| {
let show = show.read(app);
let hash = show.slides[0].effects[0].hash.clone();
let leaving = show.leaving.as_ref().expect("covered");
(object_look(&leaving.runs, &hash, true, leaving.at, 0.5), hash)
});
assert!(!hash.is_empty());
assert_eq!(look, Look::HIDDEN, "the exit holds through the transition");
}
#[test]
fn media_that_cannot_play_says_why_and_the_show_goes_on() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/media/clip.mp4");
let url = ely_lok::file_url(&clip);
update(&mut cx, &deck, |deck, cx| {
deck.design_command("media.insert", &[&url, "false", "8000", "4500"], cx)
});
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| tab.extra.media.len() == 1)
});
let show = started(&mut cx, &deck, true);
// Off the main thread the system player refuses.
cx.update(|app| show.update(app, |show, cx| show.toggle_media(0, cx)));
assert!(cx.update(|app| show.read(app).notice.is_some()));
assert!(cx.update(|app| deck.read(app).show.is_some()), "the show goes on");
// A later failure says why the same way.
cx.update(|app| show.update(app, |show, _| show.notice = None));
let seen = cx.update(|app| show.update(app, |show, cx| show.media_seen(Ok(true), cx)));
assert!(seen && cx.update(|app| show.read(app).notice.is_none()));
let failed = Err("Cannot Open".to_string());
let seen = cx.update(|app| show.update(app, |show, cx| show.media_seen(failed, cx)));
let notice = cx.update(|app| show.read(app).notice.clone());
assert!(!seen && notice.is_some_and(|n| n.contains("Cannot Open")));
assert!(cx.update(|app| deck.read(app).show.is_some()), "the show goes on");
}
+129
View File
@@ -0,0 +1,129 @@
//! The show's clock: back, late layers, released slides (C10.5 R2).
use std::time::Duration;
use ely_office::prelude::*;
use gpui::{Entity, TestAppContext};
use super::objects::{act, blank};
use super::play::{place, three_slides, typed};
use super::{settle, update};
use crate::deck::Deck;
use crate::preview::timeline::{Look, object_look};
use crate::slideshow::{Show, steps};
fn started(cx: &mut TestAppContext, deck: &Entity<Deck>) -> Entity<Show> {
update(cx, deck, |deck, cx| deck.start_show(false, cx));
settle(cx, |app| deck.read(app).show.is_some());
let show = cx.update(|app| deck.read(app).show.clone().expect("playing"));
settle(cx, |app| {
let show = show.read(app);
show.stack(0).is_some() && show.stack(1).is_some()
});
show
}
/// Slide one: a rectangle fading in for ten seconds.
fn fading_first(cx: &mut TestAppContext, deck: &Entity<Deck>) {
update(cx, deck, |deck, cx| {
let view = deck.active_tab().expect("a tab").view.clone();
view.update(cx, |view, _| view.show_part(0));
});
settle(cx, |app| {
let tab = deck.read(app).active_tab();
tab.is_some_and(|t| t.extra.slides.current == 0 && t.extra.objects.is_some())
});
act(cx, deck, ("draw.shape", &["rectangle", "true"]), |o| o.objects.len() == 3);
act(cx, deck, ("object.select", &["2", "false"]), |o| o.selection.is_some());
let add = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string();
update(cx, deck, |deck, cx| deck.design_command("animation.add", &[&add], cx));
let effects = |app: &gpui::App| {
let tab = deck.read(app).active_tab();
tab.and_then(|t| t.extra.motion.clone()).map(|(_, e)| e).unwrap_or_default()
};
settle(cx, |app| effects(app).len() == 1);
update(cx, deck, |deck, cx| {
deck.send_effect(0, serde_json::json!({ "start": "with", "duration": 10.0 }), cx)
});
settle(cx, |app| effects(app).first().is_some_and(|e| e.start == "with" && e.duration == 10.0));
}
#[test]
fn back_shows_a_slide_fully_built_even_without_clicks() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
fading_first(&mut cx, &deck);
let show = started(&mut cx, &deck);
typed(&mut cx, &show, "right left");
assert_eq!(place(&mut cx, &show), steps::Place::Slide { part: 0, clicks: 0 });
let (look, moving) = cx.update(|app| {
let show = show.read(app);
let effect = &show.slides[0].effects[0];
let runs = steps::runs(&show.slides[0].effects, &show.clicks);
let t = show.entered.elapsed().as_secs_f64();
(object_look(&runs, &effect.hash, false, t, 0.5), show.moving())
});
assert_eq!((look, moving), (Look::SHOWN, false), "nothing replays");
}
#[test]
fn a_slide_starts_its_clock_when_its_layers_come() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let show = started(&mut cx, &deck);
// Another tab's read holds the worker a while.
let worker = cx.update(|app| deck.read(app).core.worker.clone());
let (held, hold) = std::sync::mpsc::channel();
let busy = std::thread::spawn(move || {
let _lock = worker.lock().expect("the worker lock");
held.send(()).expect("told");
std::thread::sleep(Duration::from_millis(400));
});
hold.recv().expect("held");
// The read for slide three waits on the lock.
typed(&mut cx, &show, "3 enter");
settle(&cx, |app| show.read(app).stack(2).is_some());
busy.join().expect("let go");
let (waiting, since) = cx.update(|app| {
let show = show.read(app);
(show.waiting, show.entered.elapsed())
});
assert!(!waiting);
assert!(since < Duration::from_millis(300), "the clock began late: {since:?}");
}
#[test]
fn a_finished_transition_lets_the_left_slide_go() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
three_slides(&mut cx, &deck);
let fade = r#"{"preset":"fade-smoothly","duration":0.1,"advance":"click","time":0,"all":true}"#;
update(&mut cx, &deck, |deck, cx| deck.design_command("slide.transition", &[fade], cx));
settle(&cx, |app| {
let tab = deck.read(app).active_tab();
tab.and_then(|t| t.extra.motion.as_ref()).is_some_and(|(t, _)| t.duration == 0.1)
});
let show = started(&mut cx, &deck);
typed(&mut cx, &show, "right");
assert!(cx.update(|app| show.read(app).leaving.is_some()));
settle(&cx, |app| show.read(app).leaving.is_none());
let held = cx.update(|app| {
let show = show.read(app);
(0..3).filter(|at| show.stack(*at).is_some()).count()
});
assert!(held <= 2, "only the shown and next slides stay");
}
+273
View File
@@ -0,0 +1,273 @@
//! Regressions for the C10.2 review.
use ely_lok::show::{Objects, Paint};
use gpui::{Entity, TestAppContext};
use super::objects::{act, blank, objects, selection};
use super::undo_steps;
use crate::deck::Deck;
use crate::object::{NO_FILL, NO_LINE, commit, twips};
/// Commits `value` for `field` the way a panel field does.
fn send(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
field: &'static str,
command: &'static str,
value: i64,
now: Option<i64>,
) {
let panel = cx.update(|app| deck.read(app).panel.clone());
cx.update(|app| {
commit(
&panel,
field,
(value, now),
move |deck, cx| deck.object_command(command, &[&value.to_string()], cx),
app,
)
});
}
#[test]
fn clear_buttons_clear_fill_and_outline() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
for (_, command, value) in [NO_FILL, NO_LINE] {
update(&mut cx, &deck, command, value);
}
super::settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| s.fill == Paint::None && s.line == Paint::None)
});
}
fn update(cx: &mut TestAppContext, deck: &Entity<Deck>, command: &str, value: &str) {
super::update(cx, deck, |deck, cx| {
deck.object_command(command, &[value], cx)
});
}
#[test]
fn a_value_sent_again_after_undo_goes_out() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
let see = |n: i64| {
move |o: &Objects| {
o.selection
.as_ref()
.is_some_and(|s| s.transparency == Some(n))
}
};
send(
&mut cx,
&deck,
"transparency",
"object.transparency",
50,
Some(0),
);
super::settle(&cx, |app| objects(app, &deck).as_ref().is_some_and(see(50)));
// Undo may drop the selection; pick the shape again.
super::update(&mut cx, &deck, |deck, cx| {
deck.object_command("edit.undo", &[], cx)
});
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["2".into()], cx)
});
super::settle(&cx, |app| objects(app, &deck).as_ref().is_some_and(see(0)));
send(
&mut cx,
&deck,
"transparency",
"object.transparency",
50,
Some(0),
);
super::settle(&cx, |app| objects(app, &deck).as_ref().is_some_and(see(50)));
}
#[test]
fn twin_objects_each_take_the_same_value() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let count = |n: usize| move |o: &Objects| o.objects.len() == n;
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
count(3),
);
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
count(4),
);
let x = |at: usize, n: i64| move |o: &Objects| (o.objects[at].rect.0 - n).abs() <= 1;
for at in [2, 3] {
super::update(&mut cx, &deck, move |deck, cx| {
deck.select_objects(&[at.to_string()], cx)
});
super::settle(&cx, |app| {
objects(app, &deck).is_some_and(|o| o.objects[at].selected)
});
let now = cx.update(|app| selection(app, &deck)).expect("one").rect.0;
let panel = cx.update(|app| deck.read(app).panel.clone());
cx.update(|app| {
commit(
&panel,
"x",
(3000, Some(now)),
|deck, cx| deck.object_command("object.x", &[&twips(3000).to_string()], cx),
app,
)
});
super::settle(&cx, |app| {
objects(app, &deck).as_ref().is_some_and(x(at, 3000))
});
}
}
#[test]
fn lines_take_an_outline_color_and_width() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(&mut cx, &deck, ("draw.line", &[]), |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "line")
});
act(&mut cx, &deck, ("object.line_color", &["16711680"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.line == Paint::Color(0xFF0000))
});
act(&mut cx, &deck, ("object.line_width", &["176"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.line_width == Some(176))
});
}
#[test]
fn an_empty_list_choice_deselects() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
|o| o.selection.is_some(),
);
super::update(&mut cx, &deck, |deck, cx| deck.select_objects(&[], cx));
super::settle(&cx, |app| {
objects(app, &deck).is_some_and(|o| o.selection.is_none())
});
}
#[test]
fn a_mixed_value_can_be_set_to_zero() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let count = |n: usize| move |o: &Objects| o.objects.len() == n;
act(
&mut cx,
&deck,
("draw.shape", &["rectangle", "true"]),
count(3),
);
act(&mut cx, &deck, ("object.transparency", &["25"]), |o| {
o.selection
.as_ref()
.is_some_and(|s| s.transparency == Some(25))
});
act(
&mut cx,
&deck,
("draw.shape", &["ellipse", "true"]),
count(4),
);
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["2".into(), "3".into()], cx)
});
super::settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| s.count == 2 && s.transparency.is_none())
});
let steps = undo_steps(&mut cx, &deck);
send(
&mut cx,
&deck,
"transparency",
"object.transparency",
0,
None,
);
super::settle(&cx, |app| {
selection(app, &deck).is_some_and(|s| s.transparency == Some(0))
});
assert_eq!(undo_steps(&mut cx, &deck), steps + 1);
}
#[test]
fn a_mixed_autofit_takes_one_rule_for_all() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let fit = |rule: &'static str| {
move |o: &Objects| o.selection.as_ref().is_some_and(|s| s.autofit == rule)
};
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["0".into()], cx)
});
act(&mut cx, &deck, ("text.fit", &["shrink"]), fit("shrink"));
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["1".into()], cx)
});
act(&mut cx, &deck, ("text.fit", &["grow"]), fit("grow"));
super::update(&mut cx, &deck, |deck, cx| {
deck.select_objects(&["0".into(), "1".into()], cx)
});
super::settle(&cx, |app| {
objects(app, &deck).as_ref().is_some_and(fit("mixed"))
});
let steps = undo_steps(&mut cx, &deck);
act(&mut cx, &deck, ("text.fit", &["off"]), fit("off"));
assert_eq!(undo_steps(&mut cx, &deck), steps + 1, "one step for both");
}
+386
View File
@@ -0,0 +1,386 @@
//! Tables, charts, notes and comments from the deck (C10.3).
use ely_gpui_component::forms::TextInput;
use ely_lok::show::Objects;
use ely_office::prelude::*;
use gpui::{AppContext as _, Entity, TestAppContext, Window};
use super::objects::{act, blank, objects};
use super::{settle, undo_steps, update};
use crate::deck::Deck;
use crate::dialog::Dialog;
/// Runs `f` on the deck inside its window.
fn in_window<R>(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R,
) -> R {
let handle = cx
.update(|app| deck.read(app).core.window)
.expect("a window");
cx.update_window(handle, |_, window, app| {
deck.update(app, |deck, cx| f(deck, window, cx))
})
.expect("the window lives")
}
fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
let text = text.to_string();
cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx)));
}
fn has(cx: &TestAppContext, deck: &Entity<Deck>, done: impl Fn(&Objects) -> bool) {
settle(cx, |app| objects(app, deck).as_ref().is_some_and(&done));
}
#[test]
fn the_chart_dialog_inserts_edits_and_refuses_words() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let chart_open = |app: &gpui::App| matches!(deck.read(app).dialog, Some(Dialog::Chart(_)));
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
settle(&cx, chart_open);
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
has(&cx, &deck, |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "chart")
});
// Again, with the chart marked: it edits.
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
settle(&cx, chart_open);
let cell = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => {
assert!(editing.changing, "the marked chart opens for editing");
editing.table.as_ref().expect("a table").series[0].1[0].clone()
}
_ => unreachable!("a chart dialog"),
});
type_in(&mut cx, &cell, "lots");
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
let refused = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => editing.error.clone(),
_ => None,
});
assert!(refused.is_some(), "a word in a cell keeps the dialog open");
let steps = undo_steps(&mut cx, &deck);
type_in(&mut cx, &cell, "9");
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
settle(&cx, |app| deck.read(app).dialog.is_none());
assert_eq!(undo_steps(&mut cx, &deck), steps + 1, "one step");
let value = cx.update(|app| {
let deck = deck.read(app);
let tab = deck.active_tab().expect("a tab");
let mut worker = deck.core.worker.lock().expect("the worker lock");
let read = worker
.values(&tab.doc, ely_lok::sheet::CHART)
.expect("reads");
ely_lok::show::slide_chart(&read)
.expect("parses")
.expect("a chart")
.data
.expect("a table")
.series[0]
.1[0]
});
assert_eq!(value, Some(9.0));
}
#[test]
fn the_table_dialog_inserts_a_table() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.ask_table(cx));
update(&mut cx, &deck, |deck, cx| deck.table_asked(cx));
has(&cx, &deck, |o| {
o.objects.iter().any(|object| object.kind == "table")
});
}
#[test]
fn typed_notes_reach_the_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let notes = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.notes.clone())
};
settle(&cx, |app| notes(app).is_some());
let field = in_window(&mut cx, &deck, |deck, window, cx| {
deck.notes_input(window, cx)
});
let field = field.expect("the field");
type_in(&mut cx, &field, "Start with the totals");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| {
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
});
}
#[test]
fn a_comment_lands_on_the_shown_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
in_window(&mut cx, &deck, |deck, window, cx| {
deck.ask_remark(None, window, cx)
});
let (tab, edit) = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Remark { tab, edit, .. }) => (*tab, edit.clone()),
_ => unreachable!("a comment dialog"),
});
update(&mut cx, &deck, |deck, cx| {
deck.send_remark((tab, edit), " Too much text ", cx)
});
let remarks = |app: &gpui::App| {
deck.read(app)
.active_tab()
.map(|tab| tab.extra.remarks.clone())
.unwrap_or_default()
};
settle(&cx, |app| remarks(app).len() == 1);
let remark = cx.update(|app| remarks(app).remove(0));
assert_eq!((remark.part, remark.text.as_str()), (0, "Too much text"));
}
#[test]
fn notes_typed_before_a_slide_change_stay_on_their_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let notes = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.notes.clone())
};
update(&mut cx, &deck, |deck, cx| {
let tab = deck.core.active;
deck.run_in(tab, "slide.insert", &[], cx)
});
let shown =
|part: usize| move |app: &gpui::App| notes(app).is_some_and(|n| n.part == Some(part));
settle(&cx, shown(1));
let view = |deck: &mut Deck| deck.active_tab().expect("a tab").view.clone();
update(&mut cx, &deck, |deck, cx| {
view(deck).update(cx, |view, _| view.show_part(0))
});
settle(&cx, shown(0));
let field = in_window(&mut cx, &deck, |deck, window, cx| {
deck.notes_input(window, cx)
});
type_in(&mut cx, &field.expect("the field"), "Only for the first");
// The switch is queued before the blur's commit.
update(&mut cx, &deck, |deck, cx| {
view(deck).update(cx, |view, _| view.show_part(1));
deck.commit_notes(cx);
});
settle(&cx, |app| {
notes(app).is_some_and(|n| n.part == Some(1) && n.text.is_empty())
});
update(&mut cx, &deck, |deck, cx| {
view(deck).update(cx, |view, _| view.show_part(0))
});
settle(&cx, |app| {
notes(app).is_some_and(|n| n.part == Some(0) && n.text == "Only for the first")
});
}
#[test]
fn a_chart_read_that_lands_late_opens_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
settle(&cx, |app| {
matches!(deck.read(app).dialog, Some(Dialog::Chart(_)))
});
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
has(&cx, &deck, |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "chart")
});
let first = cx.update(|app| deck.read(app).core.active).expect("a tab");
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| deck.read(app).core.tabs.len() == 2);
let second = cx.update(|app| deck.read(app).core.active).expect("a tab");
update(&mut cx, &deck, |deck, _| deck.core.active = Some(first));
// Another tab comes forward before the read comes back.
update(&mut cx, &deck, |deck, cx| {
deck.ask_chart(cx);
deck.core.active = Some(second);
});
for _ in 0..100 {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
cx.run_until_parked();
}
assert!(
cx.update(|app| deck.read(app).dialog.is_none()),
"no dialog for a hidden tab"
);
}
#[test]
fn a_chart_read_never_shows_another_chart_on_the_same_frame() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let charts = |o: &Objects| -> Vec<usize> {
let at = o.objects.iter().enumerate();
at.filter(|(_, o)| o.kind == "chart")
.map(|(at, _)| at)
.collect()
};
let title = |app: &gpui::App| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => Some(editing.fields.title.read(app).text().to_string()),
_ => None,
};
for (n, name) in [(1, "First"), (2, "Second")] {
act(&mut cx, &deck, ("object.deselect", &["false"]), |o| {
o.selection.is_none()
});
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
settle(&cx, |app| title(app).is_some());
let field = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => editing.fields.title.clone(),
_ => unreachable!("a chart dialog"),
});
type_in(&mut cx, &field, name);
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
has(&cx, &deck, |o| charts(o).len() == n);
}
let both = cx.update(|app| charts(&objects(app, &deck).expect("objects")));
let first = both[0].to_string();
act(&mut cx, &deck, ("object.select", &[&first, "false"]), |o| {
o.objects[both[0]].selected
});
// The kernel picks the other chart before the read runs.
let (worker, doc) = cx.update(|app| {
let deck = deck.read(app);
(
deck.core.worker.clone(),
deck.active_tab().expect("a tab").doc.clone(),
)
});
let mut held = worker.lock().expect("the worker lock");
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
let second = both[1].to_string();
let doc = held.now(&doc).expect("the state");
held.run(&doc, "object.select", &[&second, "false"])
.expect("selects");
drop(held);
for _ in 0..100 {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
cx.run_until_parked();
}
let shown = cx.update(|app| title(app));
assert!(
shown.is_none() || shown.as_deref() == Some("First"),
"{shown:?}"
);
}
#[test]
fn a_scatter_with_words_for_x_keeps_its_draft_and_names_the_cell() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
let open = |app: &gpui::App| matches!(deck.read(app).dialog, Some(Dialog::Chart(_)));
settle(&cx, open);
update(&mut cx, &deck, |deck, cx| {
if let Some(Dialog::Chart(editing)) = &mut deck.dialog {
editing.look.kind = "scatter".into();
}
deck.chart_asked(cx);
});
let error = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => editing.error.clone(),
_ => None,
});
let first = cx.update(|app| Deck::text(app, "show.chart.category", &[("n", "1")]));
assert!(
error.is_some_and(|e| e.contains(first.as_ref())),
"the dialog stays and names it"
);
let fields = cx.update(|app| match &deck.read(app).dialog {
Some(Dialog::Chart(editing)) => editing.table.as_ref().expect("a table").categories.clone(),
_ => unreachable!("a chart dialog"),
});
for (at, field) in fields.iter().enumerate() {
type_in(&mut cx, field, &(10 * (at + 1)).to_string());
}
update(&mut cx, &deck, |deck, cx| deck.chart_asked(cx));
has(&cx, &deck, |o| {
o.selection.as_ref().is_some_and(|s| s.kind == "chart")
});
}
#[test]
fn a_comment_picked_on_another_slide_shows_its_slide_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let tab = cx.update(|app| deck.read(app).core.active).expect("a tab");
update(&mut cx, &deck, |deck, cx| {
deck.send_remark((tab, None), "Here", cx)
});
let remark = |app: &gpui::App| {
let tab = deck.read(app).active_tab()?;
tab.extra.remarks.first().map(|remark| remark.id.clone())
};
settle(&cx, |app| remark(app).is_some());
let key: gpui::SharedString = cx.update(|app| remark(app)).expect("a comment").into();
let current = |app: &gpui::App| {
deck.read(app)
.active_tab()
.map(|tab| tab.extra.slides.current)
};
update(&mut cx, &deck, |deck, cx| deck.pick_remark(&key, cx));
assert_eq!(
cx.update(|app| deck.read(app).shown_remark()),
Some(key.clone())
);
update(&mut cx, &deck, |deck, cx| {
deck.run_in(Some(tab), "slide.insert", &[], cx)
});
settle(&cx, |app| current(app) == Some(1));
assert_eq!(
cx.update(|app| deck.read(app).shown_remark()),
None,
"pickable again"
);
update(&mut cx, &deck, |deck, cx| deck.pick_remark(&key, cx));
settle(&cx, |app| current(app) == Some(0));
assert_eq!(cx.update(|app| deck.read(app).shown_remark()), Some(key));
}