ElyShow: typed notes survive a refused send and a read while typing (S-08)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 21:02:57 -04:00
1 parent 33df906449
commit b46fedb327
7 files changed
+106 -30

No files matched your search

+5 -2
View File
@@ -20,7 +20,7 @@ pub struct NotesField {
field: Option<Entity<TextInput>>,
shown: Option<(u64, usize, String)>,
/// The state the shown slide number was read in.
at: Option<ely_lok::Doc>,
pub(crate) at: Option<ely_lok::Doc>,
_events: Option<gpui::Subscription>,
}
@@ -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)
}
+1
View File
@@ -9,6 +9,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
use crate::deck::Deck;
mod motion;
mod notes;
mod objects;
mod output;
mod play;
+93
View File
@@ -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"));
}
+2 -27
View File
@@ -11,7 +11,7 @@ use crate::deck::Deck;
use crate::dialog::Dialog;
/// Runs `f` on the deck inside its window.
fn in_window<R>(
pub(super) fn in_window<R>(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R,
@@ -25,7 +25,7 @@ fn in_window<R>(
.expect("the window lives")
}
fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
pub(super) 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)));
}
@@ -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");
+1 -1
View File
@@ -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
+3
View File
@@ -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`。
+1
View File
@@ -0,0 +1 @@
PASS