diff --git a/apps/ely-show/src/slideshow/start.rs b/apps/ely-show/src/slideshow/start.rs index 855a587..d61ce44 100644 --- a/apps/ely-show/src/slideshow/start.rs +++ b/apps/ely-show/src/slideshow/start.rs @@ -141,7 +141,9 @@ impl Deck { /// Plays a copy from the first or shown slide. pub fn start_show(&mut self, here: bool, cx: &mut Context) { if self.show.is_some() || self.show_starting { - return log::info!("show: already playing or starting"); + log::info!("show: already playing or starting"); + let text = Deck::text(cx, "show.play.busy", &[]); + return self.toast(text, Severity::Warning, cx); } let Some(id) = self.core.active else { return; @@ -180,7 +182,9 @@ impl Deck { let Some(first) = first.filter(|_| deck.core.active == Some(id)) else { discard(worker, doc, path, cx); if deck.core.active != Some(id) { - return log::info!("show: tab {id} is no longer shown"); + log::info!("show: tab {id} is no longer shown"); + let text = Deck::text(cx, "show.play.gone", &[]); + return deck.toast(text, Severity::Warning, cx); } let text = Deck::text(cx, "show.play.none", &[]); return deck.toast(text, Severity::Warning, cx); @@ -214,6 +218,8 @@ impl Deck { let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience)) else { log::warn!("show: the tab or screen {} is gone", starting.audience); + let text = Deck::text(cx, "show.play.gone", &[]); + self.toast(text, Severity::Warning, cx); return discard(worker, starting.doc, starting.copy, cx); }; let presenter = (displays.iter()) @@ -347,4 +353,45 @@ mod tests { }; assert_eq!(super::bounds(area), want); } + + #[test] + fn a_start_whose_screen_is_gone_says_so_and_drops_its_copy() { + use ely_office::prelude::*; + use gpui::AppContext as _; + + use crate::tests::{settle, update}; + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = gpui::TestAppContext::single(); + let Some(deck) = crate::tests::deck(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + update(&mut cx, &deck, |deck, cx| deck.new_blank(cx)); + settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| !tab.extra.slides.list.is_empty())); + // The window's toasts live on its root. + let handle = cx.update(|app| deck.read(app).core.window).expect("a window"); + let root = cx + .update_window(handle, |_, window, app| { + let root = ely_ui::document_root(ely_platform::AppId::Show, deck.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app); + deck.update(app, |deck, _| deck.core.root = root.downgrade()); + root + }) + .expect("the window lives"); + let path = dir.path().join("copy.odp"); + let starting = 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 (_, doc, slides) = super::copy(&mut worker, &tab.doc, (&path, None)).expect("a copy"); + super::Starting { tab: tab.id, slides, first: 0, doc, copy: path.clone(), audience: 99, presenter: false } + }); + update(&mut cx, &deck, |deck, cx| { + deck.show_starting = true; + deck.open_show(starting, cx); + }); + settle(&cx, |_| !path.exists()); + let (toasts, gone) = cx.update(|app| (root.read(app).toasts(app), crate::deck::Deck::text(app, "show.play.gone", &[]))); + assert!(toasts.iter().any(|(text, _)| *text == gone), "{toasts:?}"); + assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none())); + } } diff --git a/apps/ely-show/src/tests.rs b/apps/ely-show/src/tests.rs index 05e3826..554db49 100644 --- a/apps/ely-show/src/tests.rs +++ b/apps/ely-show/src/tests.rs @@ -45,7 +45,7 @@ pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option> /// Steps the scheduler until `done` or ten seconds pass. #[track_caller] -fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) { +pub(super) fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); while std::time::Instant::now() < deadline { if !cx.executor().tick() { @@ -59,7 +59,7 @@ fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) { panic!("the state never settled"); } -fn update( +pub(super) fn update( cx: &mut TestAppContext, deck: &Entity, f: impl FnOnce(&mut Deck, &mut gpui::Context), diff --git a/apps/ely-show/src/tests/play.rs b/apps/ely-show/src/tests/play.rs index 9697034..417812b 100644 --- a/apps/ely-show/src/tests/play.rs +++ b/apps/ely-show/src/tests/play.rs @@ -145,3 +145,56 @@ fn a_click_on_media_finds_it_and_elsewhere_does_not() { 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); } + +#[test] +fn a_second_start_says_the_show_is_busy() { + 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 window's toasts live on its root. + let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| { + let this = cx.entity(); + let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx); + deck.core.root = root.downgrade(); + root + }); + update(&mut cx, &deck, |deck, cx| { + deck.show_starting = true; + deck.start_show(false, cx); + }); + cx.run_until_parked(); + let (toasts, busy) = cx.update(|app| (root.read(app).toasts(app), Deck::text(app, "show.play.busy", &[]))); + assert!(toasts.iter().any(|(text, _)| *text == busy), "{toasts:?}"); +} + +#[test] +fn a_start_left_for_another_tab_says_so() { + 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 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 && deck.read(app).core.active != Some(first)); + let second = cx.update(|app| deck.read(app).core.active.expect("a tab")); + let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| { + let this = cx.entity(); + let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx); + deck.core.root = root.downgrade(); + root + }); + update(&mut cx, &deck, |deck, cx| { + deck.core.active = Some(first); + deck.start_show(false, cx); + // Another tab comes forward while the copy is read. + deck.core.active = Some(second); + }); + let gone = cx.update(|app| Deck::text(app, "show.play.gone", &[])); + settle(&cx, |app| root.read(app).toasts(app).iter().any(|(text, _)| *text == gone)); + assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none())); +} diff --git a/crates/ely-ui/src/i18n/play.rs b/crates/ely-ui/src/i18n/play.rs index 0e72c03..182aaa0 100644 --- a/crates/ely-ui/src/i18n/play.rs +++ b/crates/ely-ui/src/i18n/play.rs @@ -10,6 +10,8 @@ pub(super) const EN: &[(&str, &str)] = &[ ("show.play.screen", "Screen {number} ({width} × {height})"), ("show.play.start", "Start"), ("show.play.none", "Every slide is hidden"), + ("show.play.busy", "The show is already starting or playing"), + ("show.play.gone", "The show did not start: its deck or screen changed"), ("show.play.back", "Previous"), ("show.play.next", "Next"), ("show.play.place", "Slide {number} of {count}"), @@ -70,6 +72,8 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("show.play.screen", "屏幕 {number}({width} × {height})"), ("show.play.start", "开始放映"), ("show.play.none", "所有幻灯片都已隐藏"), + ("show.play.busy", "放映已在开始或进行中"), + ("show.play.gone", "放映未开始:文稿或屏幕已变"), ("show.play.back", "上一步"), ("show.play.next", "下一步"), ("show.play.place", "第 {number} 张,共 {count} 张"), diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 6f1bdd2..694a169 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -32,7 +32,7 @@ - [x] S-07 母版视图下演示检查的行按幻灯片序号跳转 — 母版视图下点行提示先关母版,不跳;R1 PASS - [x] S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢 — 发出的备注待读回确认,键入中读回只移基线不动草稿;R2 PASS - [x] S-09 改尺寸失败后 `size_asked` 一直留着 — 所要尺寸带输入序号,发送前先记、未入队即清,命令落地后的读回即清;R2 PASS -- [ ] S-10 开始放映的两处拒绝无提示(规则 2) +- [x] S-10 开始放映的两处拒绝无提示(规则 2) — 三处拒绝都提示(另含读回时已换标签);R2 PASS - [x] P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃 — worker 的拒绝改为自有错误类型 Refused;R1 测试不跨平台、不看提示;组件库 f834f55 加 Toaster::shown;R2 PASS - [x] P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失 — 图块随 R2.6;缩略图在编辑后留到重画;R1、R2 测试未证实际绘制;R3 PASS - [x] P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图 — 包住文字块的对象让位于文字,移动须过 3 个窗口像素;R1 漏掉同点的小图;R2 PASS diff --git a/tasks/reviews/S-10-r1.md b/tasks/reviews/S-10-r1.md new file mode 100644 index 0000000..6c8f8a5 --- /dev/null +++ b/tasks/reviews/S-10-r1.md @@ -0,0 +1,3 @@ +FAIL + +apps/ely-show/src/tests/play.rs:149:S-10 的 gone 拒绝路径缺少回归测试,新增用例只断言 show_starting 时的 busy toast。具体场景:放映副本读取期间切换标签,或选屏后原标签/观众屏消失再开始放映;这些路径分别经过 apps/ely-show/src/slideshow/start.rs:183 和 :218。验证:在独立副本中移除两处 show.play.gone toast,保留日志与 discard,原有 13 个放映测试仍全部通过;本轮补充的两个定向探针在当前实现上通过,移除 toast 后均因窗口根的提示列表为空而失败。修复方向:为上述两条拒绝路径补充回归测试,覆盖标签切换、标签消失与观众屏失效,断言窗口根出现 show.play.gone、启动状态解除且放映副本被清理。 diff --git a/tasks/reviews/S-10-r2.md b/tasks/reviews/S-10-r2.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/S-10-r2.md @@ -0,0 +1 @@ +PASS