From b46fedb327e93bd9cdf323fbcdc5fd23b7c80250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 21:02:57 -0400 Subject: [PATCH] ElyShow: typed notes survive a refused send and a read while typing (S-08) --- apps/ely-show/src/notes.rs | 7 ++- apps/ely-show/src/tests.rs | 1 + apps/ely-show/src/tests/notes.rs | 93 +++++++++++++++++++++++++++++++ apps/ely-show/src/tests/slides.rs | 29 +--------- tasks/BUGS.md | 2 +- tasks/reviews/S-08-r1.md | 3 + tasks/reviews/S-08-r2.md | 1 + 7 files changed, 106 insertions(+), 30 deletions(-) create mode 100644 apps/ely-show/src/tests/notes.rs create mode 100644 tasks/reviews/S-08-r1.md create mode 100644 tasks/reviews/S-08-r2.md diff --git a/apps/ely-show/src/notes.rs b/apps/ely-show/src/notes.rs index 047b4e0..ef387dd 100644 --- a/apps/ely-show/src/notes.rs +++ b/apps/ely-show/src/notes.rs @@ -20,7 +20,7 @@ pub struct NotesField { field: Option>, shown: Option<(u64, usize, String)>, /// The state the shown slide number was read in. - at: Option, + pub(crate) at: Option, _events: Option, } @@ -66,7 +66,7 @@ impl Deck { return log::warn!("show: notes for slide {part} have no read state"); }; log::info!("show: notes for slide {part} changed"); - self.notes.shown = Some((tab, part, text.clone())); + // `shown` stays the kernel's until a read confirms the send. self.run_on(tab, &at, "notes.set", &[&text, &part.to_string()], cx); self.force_reread(tab, cx); } @@ -90,6 +90,9 @@ impl Deck { if !typing && self.notes.shown.as_ref() != Some(&read) { field.update(cx, |field, cx| field.set_text(notes.text, cx)); self.notes.shown = Some(read); + } else if let Some(shown) = self.notes.shown.as_mut().filter(|shown| (shown.0, shown.1) == (id, part)) { + // A read while typing moves the base, not the draft. + shown.2 = notes.text; } Some(field) } diff --git a/apps/ely-show/src/tests.rs b/apps/ely-show/src/tests.rs index 8f48bef..05e3826 100644 --- a/apps/ely-show/src/tests.rs +++ b/apps/ely-show/src/tests.rs @@ -9,6 +9,7 @@ use gpui::{AppContext as _, Entity, TestAppContext}; use crate::deck::Deck; mod motion; +mod notes; mod objects; mod output; mod play; diff --git a/apps/ely-show/src/tests/notes.rs b/apps/ely-show/src/tests/notes.rs new file mode 100644 index 0000000..3184fc6 --- /dev/null +++ b/apps/ely-show/src/tests/notes.rs @@ -0,0 +1,93 @@ +//! The notes field under the slide (SHOW-08). + +use ely_office::prelude::*; +use gpui::TestAppContext; + +use super::objects::blank; +use super::slides::{in_window, type_in}; +use super::{settle, update}; + +#[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 notes_refused_as_stale_stay_typed_and_go_next_time() { + 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()); + let text = |app: &gpui::App| notes(app).map(|notes| notes.text); + settle(&cx, |app| notes(app).is_some()); + let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field"); + let stale = cx.update(|app| deck.read(app).notes.at.clone()); + type_in(&mut cx, &field, "first"); + update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx)); + settle(&cx, |app| text(app).as_deref() == Some("first")); + in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)); + // Typing began before the edit above was read back. + type_in(&mut cx, &field, "second"); + update(&mut cx, &deck, |deck, cx| { + deck.notes.at = stale; + deck.commit_notes(cx); + }); + settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.view.read(app).settled())); + cx.run_until_parked(); + in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)); + assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the typing stays"); + assert_eq!(cx.update(|app| text(app)).as_deref(), Some("first"), "the stale send was refused"); + update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx)); + settle(&cx, |app| text(app).as_deref() == Some("second")); +} + +#[test] +fn a_read_while_typing_keeps_the_new_draft() { + 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 text = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.notes.clone()).map(|notes| notes.text); + settle(&cx, |app| text(app).is_some()); + let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field"); + type_in(&mut cx, &field, "first"); + update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx)); + // Back in the field before the send is read back. + in_window(&mut cx, &deck, |_, window, cx| window.focus(&gpui::Focusable::focus_handle(field.read(cx), cx), cx)); + type_in(&mut cx, &field, "second"); + settle(&cx, |app| text(app).as_deref() == Some("first")); + in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)); + // The blur redraws before its commit runs. + in_window(&mut cx, &deck, |deck, window, cx| { + window.blur(cx); + deck.notes_input(window, cx) + }); + assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the draft stays"); + update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx)); + settle(&cx, |app| text(app).as_deref() == Some("second")); +} diff --git a/apps/ely-show/src/tests/slides.rs b/apps/ely-show/src/tests/slides.rs index 5bf35ae..24ef8ed 100644 --- a/apps/ely-show/src/tests/slides.rs +++ b/apps/ely-show/src/tests/slides.rs @@ -11,7 +11,7 @@ use crate::deck::Deck; use crate::dialog::Dialog; /// Runs `f` on the deck inside its window. -fn in_window( +pub(super) fn in_window( cx: &mut TestAppContext, deck: &Entity, f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context) -> R, @@ -25,7 +25,7 @@ fn in_window( .expect("the window lives") } -fn type_in(cx: &mut TestAppContext, field: &Entity, text: &str) { +pub(super) fn type_in(cx: &mut TestAppContext, field: &Entity, text: &str) { let text = text.to_string(); cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx))); } @@ -121,31 +121,6 @@ fn an_open_dialog_is_not_pushed_aside() { assert!(kept, "the table dialog stays"); } -#[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"); diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 690c294..433b923 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -30,7 +30,7 @@ - [x] S-05 一条批注的时间恰为午夜或只有日期,整个批注栏不显示 — 读不出时间的批注只占一行汇总,其余照常显示(jiff 本可读只有日期的时间);R2 PASS - [x] S-06 导出或打印的“从–到”填 0 时导出全部幻灯片 — 键入的页码夹到 1..=末页;R1 PASS - [x] S-07 母版视图下演示检查的行按幻灯片序号跳转 — 母版视图下点行提示先关母版,不跳;R1 PASS -- [ ] S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢 +- [x] S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢 — 发出的备注待读回确认,键入中读回只移基线不动草稿;R2 PASS - [ ] S-09 改尺寸失败后 `size_asked` 一直留着 - [ ] S-10 开始放映的两处拒绝无提示(规则 2) - [x] P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃 — worker 的拒绝改为自有错误类型 Refused;R1 测试不跨平台、不看提示;组件库 f834f55 加 Toaster::shown;R2 PASS diff --git a/tasks/reviews/S-08-r1.md b/tasks/reviews/S-08-r1.md new file mode 100644 index 0000000..81110be --- /dev/null +++ b/tasks/reviews/S-08-r1.md @@ -0,0 +1,3 @@ +FAIL + +apps/ely-show/src/notes.rs:90(关联 apps/ely-show/src/notes.rs:69、apps/ely-show/src/tests/slides.rs:462):取消发送时更新 `shown` 后,聚焦期间到达的成功重读也继续保留提交前的 `shown`,下一次失焦绘制会覆盖新草稿,违反 SHOW-08。具体场景:空备注框键入 `first`,失焦发送后立即重新聚焦并键入 `second`;保持聚焦直到内核读回 `first`,随后失焦。此时 `shown` 仍为空,`notes_input` 在 Blur 提交回调之前执行 `set_text("first")`,`second` 被丢弃。隔离副本用真实 LOK、渲染的 Input、窗口激活及聚焦/失焦复现,断言得到 `first`,预期 `second`;恢复旧的提前更新后同一探针通过。现有测试第 462 行先在未聚焦状态更新基线,再手工恢复过期 `at`,遗漏这条时序。修复方向:同一标签和幻灯片的成功读回应在聚焦期间同步已确认文本基线,同时保留草稿;加入聚焦期间成功读回、随后失焦绘制的回归测试。仅在隔离副本同步该基线后,新探针及三个原有备注测试全部通过。复现源码与日志:`/tmp/ely-s08-r1-repro.log`、`/tmp/ely-s08-r1-render-focus.log`、`/tmp/ely-s08-r1-old-focus.log`、`/tmp/ely-s08-r1-confirmed-baseline.log`。 diff --git a/tasks/reviews/S-08-r2.md b/tasks/reviews/S-08-r2.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/S-08-r2.md @@ -0,0 +1 @@ +PASS