ElyDoc: one unreadable note time spares the other notes and the changes; each is named, read aloud and bounded (D-04)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-10 01:27:20 -04:00
1 parent 530a0cd261
commit c13787f71d
6 files changed
+177 -27

No files matched your search

+1
View File
@@ -146,6 +146,7 @@ tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构
tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板)
tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限)
tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限)
tests/scripts/doc-review.sh 真窗口 ElyDoc:无日期的批注逐条写明作者与原因(AX 可读),多条时批注与修订列表仍在窗口内(需辅助功能权限)
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
+69 -26
View File
@@ -8,11 +8,11 @@ use std::{
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
collab::{Comment, CommentSidebar, Peer, Thread},
feedback::EmptyState,
feedback::{EmptyState, InlineMessage},
forms::{Switch, TextInput},
layout::ScrollArea,
lists::{List, ListItem},
primitives::IconName,
primitives::{IconName, Severity},
theme::ControlSize,
};
use ely_lok::{
@@ -20,7 +20,7 @@ use ely_lok::{
review::{Change, Note},
};
use gpui::{
App, Context, Entity, EventEmitter, IntoElement, Menu, MenuItem, ParentElement as _, Render,
App, Context, Entity, EventEmitter, InteractiveElement as _, IntoElement, Menu, StatefulInteractiveElement as _, MenuItem, ParentElement as _, Render,
SharedString, Styled as _, Subscription, Window, actions, div,
};
@@ -86,6 +86,8 @@ pub struct Review {
shown: Option<Shown>,
notes: Vec<Note>,
threads: Vec<Thread>,
/// Notes whose time does not read: time and why.
unread: Vec<Unread>,
pub(crate) changes: Vec<Change>,
active: Option<SharedString>,
pane: Pane,
@@ -100,6 +102,7 @@ impl Review {
shown: None,
notes: Vec::new(),
threads: Vec::new(),
unread: Vec::new(),
changes: Vec::new(),
active: None,
pane: Pane::Comments,
@@ -117,6 +120,7 @@ impl Review {
self.active = None;
self.notes.clear();
self.threads.clear();
self.unread.clear();
self.changes.clear();
cx.notify();
let Some((view, doc)) = tab else {
@@ -161,21 +165,8 @@ impl Review {
return;
}
shown.reading = false;
let read = read.and_then(|(notes, changes)| {
let threads = threads(&notes).map_err(std::io::Error::other)?;
Ok((notes, threads, changes))
});
match read {
Ok((notes, threads, changes)) => {
log::info!(
"doc: review lists {} notes, {} changes",
notes.len(),
changes.len()
);
review.notes = notes;
review.threads = threads;
review.changes = changes;
}
Ok((notes, changes)) => review.take_lists(notes, changes),
Err(error) => cx.emit(LokError(format!(
"the review lists are unreadable: {error}"
))),
@@ -366,12 +357,61 @@ impl Render for Review {
.p_2()
.child(head)
.child(pane)
// Many bad notes scroll; the lists stay in reach.
.children(self.unread_alert(cx).map(|alert| {
ScrollArea::new("doc-review-unread-scroll").vertical().w_full().max_h(gpui::rems(6.)).flex_none().child(alert)
}))
.child(div().flex_1().min_h_0().child(body))
}
}
/// The notes as threads under their first comment.
fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
/// A note whose time does not read, and why.
#[derive(Clone, Debug, PartialEq)]
pub struct Unread {
pub author: String,
pub at: String,
pub error: String,
}
impl Review {
/// The kernel's lists; one bad time spares the rest.
fn take_lists(&mut self, notes: Vec<Note>, changes: Vec<Change>) {
let (threads, unread) = threads(&notes);
for bad in &unread {
log::error!("doc: a bad comment time {:?}: {}", bad.at, bad.error);
}
log::info!("doc: review lists {} notes, {} changes", notes.len(), changes.len());
(self.notes, self.threads, self.unread, self.changes) = (notes, threads, unread, changes);
}
/// Each unreadable note, by its author and the reason.
fn unread_lines(&self, cx: &App) -> Vec<SharedString> {
let line = |bad: &Unread| {
Docs::text(cx, "doc.review.unread", &[("author", &bad.author), ("at", &bad.at), ("error", &bad.error)])
};
self.unread.iter().map(line).collect()
}
/// The lines as one alert assistive tech reads whole.
fn unread_alert(&self, cx: &App) -> Option<gpui::AnyElement> {
let lines = self.unread_lines(cx);
let said: SharedString = lines.join("\n").into();
(!lines.is_empty()).then(|| {
div()
.id("doc-review-unread")
.role(gpui::Role::Alert)
.aria_label(said)
.flex()
.flex_col()
.gap_1()
.children(lines.into_iter().map(|line| InlineMessage::new(Severity::Danger, line)))
.into_any_element()
})
}
}
/// Notes threaded under their first comment; bad times apart.
fn threads(notes: &[Note]) -> (Vec<Thread>, Vec<Unread>) {
let parents: HashMap<&str, &str> = notes
.iter()
.map(|note| (note.id.as_str(), note.parent.as_str()))
@@ -384,13 +424,16 @@ fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
id.to_string()
};
let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect();
let mut threads: Vec<Thread> = Vec::new();
let (mut threads, mut unread): (Vec<Thread>, Vec<Unread>) = (Vec::new(), Vec::new());
for note in notes {
let at = note
.at
.parse::<jiff::civil::DateTime>()
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
.map_err(|error| format!("a bad comment time {}: {error}", note.at))?;
let read = note.at.parse::<jiff::civil::DateTime>().and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()));
let at = match read {
Ok(at) => at,
Err(error) => {
unread.push(Unread { author: note.author.clone(), at: note.at.clone(), error: error.to_string() });
continue;
}
};
let hue = authors
.iter()
.position(|name| *name == note.author)
@@ -412,7 +455,7 @@ fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
}),
}
}
Ok(threads)
(threads, unread)
}
#[cfg(test)]
+37
View File
@@ -82,3 +82,40 @@ fn comments_thread_and_changes_accept_or_reject() {
}
assert!(!text.contains("gone"), "the rejected words go");
}
#[test]
fn one_unreadable_time_spares_the_other_notes() {
let note = |id: &str, parent: &str, at: &str| Note {
id: id.into(),
parent: parent.into(),
author: "A".into(),
text: "t".into(),
resolved: false,
at: at.into(),
anchor: (0, 0, 0, 0),
};
// The kernel writes an empty date as 0-00-00.
let mut undated = note("2", "0", "0-00-00");
undated.author = "Undated".into();
let notes = vec![note("1", "0", "2025-03-01T10:00:00"), undated, note("3", "1", "2025-03-02T09:00:00")];
let (threads, unread) = threads(&notes);
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].comments.len(), 2, "the reply stays in its thread");
assert_eq!((unread.len(), unread[0].author.as_str(), unread[0].at.as_str()), (1, "Undated", "0-00-00"));
// Through the refresh path: changes stay, the line names both.
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = gpui::TestAppContext::single();
let Some(docs) = crate::tests::docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let review = cx.update(|app| docs.read(app).review.clone());
let change = Change { index: 7, author: "B".into(), kind: "Insert".into(), description: String::new(), at: "2025-03-01T10:00:00".into(), anchor: None };
let lines = cx.update(|cx| review.update(cx, |review, cx| {
review.take_lists(notes, vec![change]);
assert_eq!(review.changes.len(), 1, "the changes stay");
review.unread_lines(cx)
}));
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("Undated") && lines[0].contains(&unread[0].error), "{}", lines[0]);
}
+8
View File
@@ -50,6 +50,10 @@ pub(super) const EN: &[(&str, &str)] = &[
("doc.review.accept_all", "Accept All"),
("doc.review.reject_all", "Reject All"),
("doc.review.panel", "Comments and Changes"),
(
"doc.review.unread",
"A comment by {author} has an unreadable time {at}: {error}",
),
("doc.review.comments", "Comments {n}"),
("doc.review.changes", "Changes {n}"),
("doc.review.accept", "Accept"),
@@ -101,6 +105,10 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("doc.review.accept_all", "全部接受"),
("doc.review.reject_all", "全部拒绝"),
("doc.review.panel", "批注与修订"),
(
"doc.review.unread",
"{author} 的一条批注时间读不出({at}):{error}",
),
("doc.review.comments", "批注 {n}"),
("doc.review.changes", "修订 {n}"),
("doc.review.accept", "接受"),
+1 -1
View File
@@ -50,7 +50,7 @@
- [x] D-01 导出或打印对话框开着时关闭其标签即崩溃 — Docs 实现 tab_closed,关标签即撤下其导出对话框;R1 PASS
- [x] D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录 — 选数据时记下模板标签,预览副本不作模板;R1 测试绕过选 CSV 入口;R2 PASS
- [x] D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名) — 后台读取,回来核对标签、视图与对话框请求号
- [ ] D-04 一条批注的时间读不出,全部批注与修订都不显示
- [x] D-04 一条批注的时间读不出,全部批注与修订都不显示 — 逐条解析,读不出的批注写明作者与原因(AX 可读、可滚动),修订照留
- [ ] D-05 邮件合并某条记录失败时内核文档泄漏
- [ ] D-06 导航栏大纲点击按当前列表取下标,后台重读后可能越界
- [ ] D-07 导航栏隐藏时仍随每次光标移动重读大纲与书签
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# ElyDoc: undated notes are named; the lists stay visible.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
trap 'kill "${pid:-0}" 2>/dev/null || true; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/axv" "$root/tests/scripts/ax-values.swift"
[ "$("$work/axv" $$)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
# One dated note, twenty without a date, one tracked insertion.
python3 - "$work/notes.docx" <<'PY'
import sys, zipfile
W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
notes = ['<w:comment w:id="0" w:author="ValidAuthor" w:date="2025-03-01T10:00:00Z"><w:p><w:r><w:t>Valid note</w:t></w:r></w:p></w:comment>']
paras = []
for i in range(21):
if i:
notes.append(f'<w:comment w:id="{i}" w:author="Undated{i}"><w:p><w:r><w:t>Note {i}</w:t></w:r></w:p></w:comment>')
paras.append(f'<w:p><w:commentRangeStart w:id="{i}"/><w:r><w:t>Para {i}</w:t></w:r><w:commentRangeEnd w:id="{i}"/><w:r><w:commentReference w:id="{i}"/></w:r></w:p>')
paras.append('<w:p><w:ins w:id="50" w:author="ChangeAuthor" w:date="2025-03-01T10:00:00Z"><w:r><w:t>Tracked</w:t></w:r></w:ins></w:p>')
rel = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
files = {
'[Content_Types].xml': '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/></Types>',
'_rels/.rels': f'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{rel}/officeDocument" Target="word/document.xml"/></Relationships>',
'word/_rels/document.xml.rels': f'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{rel}/comments" Target="comments.xml"/></Relationships>',
'word/comments.xml': f'<?xml version="1.0" encoding="UTF-8"?><w:comments {W}>' + ''.join(notes) + '</w:comments>',
'word/document.xml': f'<?xml version="1.0" encoding="UTF-8"?><w:document {W}><w:body>' + ''.join(paras) + '</w:body></w:document>',
}
with zipfile.ZipFile(sys.argv[1], 'w') as z:
for name, text in files.items():
z.writestr(name, text)
PY
ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" XDG_DATA_HOME="$work/data" \
perl -e 'alarm 120; exec @ARGV' "$root/target/debug/ely-doc" "$work/notes.docx" >"$work/out" 2>"$work/err" &
pid=$!
log() { find "$work" -path '*/ElyOffice/ely-doc/ely.log' 2>/dev/null | head -n 1; }
for _ in $(seq 200); do grep -q "review lists 21 notes, 1 changes" "$(log)" 2>/dev/null && break; sleep 0.2; done
grep -q "review lists 21 notes, 1 changes" "$(log)" || fail "the lists did not read: $(tail -n 3 "$(log)")"
"$work/axv" "$pid" >/dev/null
osascript -e "tell application \"System Events\" to tell (first process whose unix id is $pid)" -e 'set frontmost to true' \
-e 'click menu item "Comments and Changes" of menu "Review" of menu bar 1' -e 'end tell' >/dev/null
seen=
for _ in $(seq 10); do
seen=$("$work/axv" "$pid")
grep -q "ValidAuthor: Valid note" <<<"$seen" && break
sleep 1
done
[ "$(grep -c "A comment by Undated[0-9]* has an unreadable time" <<<"$seen")" = 20 ] || fail "each undated note is not named"
# The valid note's center lies inside the window.
read -r top height < <(osascript -e "tell application \"System Events\" to tell (first process whose unix id is $pid)" \
-e 'set {x, y} to position of window 1' -e 'set {w, h} to size of window 1' -e 'return (y as text) & " " & (h as text)' -e 'end tell')
at=$(grep "ValidAuthor: Valid note" <<<"$seen" | head -n 1 | sed 's/.*at=[0-9]*,//')
[ -n "$at" ] && [ "$at" -lt $((top + height)) ] || fail "the valid note sits below the window ($at, window ends $((top + height)))"
echo "ok: doc-review"