ElyDoc: mail merge keeps the template chosen with the data; previews are never templates (D-02)
This commit is contained in:
9 files changed
+102
-17
No files matched your search
@@ -36,6 +36,10 @@ pub fn menu(cx: &gpui::App) -> gpui::Menu {
|
||||
pub struct Merge {
|
||||
docs: WeakEntity<Docs>,
|
||||
pub table: Option<Table>,
|
||||
/// The template's tab, active when the data was chosen.
|
||||
pub source: Option<u64>,
|
||||
/// Tabs opened as previews; none is a template.
|
||||
pub previews: std::collections::HashSet<u64>,
|
||||
/// The record to preview, from 1.
|
||||
record: usize,
|
||||
pdf: bool,
|
||||
@@ -46,6 +50,8 @@ impl Merge {
|
||||
Merge {
|
||||
docs,
|
||||
table: None,
|
||||
source: None,
|
||||
previews: std::collections::HashSet::new(),
|
||||
record: 1,
|
||||
pdf: false,
|
||||
}
|
||||
@@ -62,11 +68,20 @@ impl Docs {
|
||||
}
|
||||
|
||||
pub(crate) fn choose_csv(&mut self, cx: &mut Context<Self>) {
|
||||
self.pick(false, cx, |docs, path, cx| match read_csv(&path) {
|
||||
let Some(source) = self.core.active else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
if self.merge.read(cx).previews.contains(&source) {
|
||||
let text = Docs::text(cx, "doc.merge.from_preview", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
self.pick(false, cx, move |docs, path, cx| match read_csv(&path) {
|
||||
Ok(table) => {
|
||||
log::info!("doc: merge data has {} rows", table.rows.len());
|
||||
docs.merge.update(cx, |merge, cx| {
|
||||
merge.table = Some(table);
|
||||
merge.source = Some(source);
|
||||
merge.record = 1;
|
||||
cx.notify();
|
||||
});
|
||||
@@ -84,20 +99,20 @@ impl Docs {
|
||||
});
|
||||
}
|
||||
|
||||
/// The tab a merge starts from: the active one.
|
||||
/// The template's tab, while it is open.
|
||||
fn source(&self, cx: &mut Context<Self>) -> Option<u64> {
|
||||
if self.core.active.is_none() {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
let source = self.merge.read(cx).source.filter(|source| self.tab(*source).is_some());
|
||||
if source.is_none() {
|
||||
let text = Docs::text(cx, "doc.merge.gone", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
self.core.active
|
||||
source
|
||||
}
|
||||
|
||||
/// Types a field's marker at the cursor.
|
||||
/// Types a field's marker at the template's cursor.
|
||||
pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
let Some(tab) = self.source(cx).and_then(|source| self.tab(source)) else {
|
||||
return;
|
||||
};
|
||||
tab.view.update(cx, |view, _| view.insert(&marker(header)));
|
||||
}
|
||||
@@ -194,7 +209,12 @@ impl Render for Merge {
|
||||
})
|
||||
};
|
||||
let row = || div().flex().flex_wrap().items_center().gap_1();
|
||||
let template = self.source.and_then(|source| {
|
||||
let docs = self.docs.upgrade()?;
|
||||
Some(docs.read(cx).tab(source)?.name.clone())
|
||||
});
|
||||
let group = PropertyGroup::new(table.name.clone())
|
||||
.row(text("doc.merge.template"), div().child(template.unwrap_or_else(|| text("doc.merge.gone"))))
|
||||
.row(text("doc.merge.data"), choose)
|
||||
.row(text("doc.merge.fields"), row().children(fields))
|
||||
.row(
|
||||
|
||||
@@ -164,6 +164,7 @@ impl Docs {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
log::info!("doc: previewing merge record {}", at + 1);
|
||||
self.merge.update(cx, |merge, _| merge.previews.insert(into));
|
||||
self.load_into(into, Source::Copy(copy), cx);
|
||||
self.core.waiting.push((
|
||||
into,
|
||||
|
||||
@@ -22,9 +22,9 @@ fn records_fill_previews_and_files() {
|
||||
let csv = dir.path().join("people.csv");
|
||||
std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
|
||||
type_into(&mut cx, &docs, " of ");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("City", cx)));
|
||||
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
@@ -171,7 +171,7 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.insert_field("Name", cx);
|
||||
docs.field_here("Name", cx);
|
||||
docs.preview_record(source, &table, 0, cx);
|
||||
docs.core.active = Some(other);
|
||||
})
|
||||
@@ -191,6 +191,14 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
|
||||
});
|
||||
let preview = dir.path().join("preview.odt");
|
||||
let id = cx.update(|app| docs.read(app).core.tabs[2].id);
|
||||
// The preview is active now; the template stays.
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.core.active = Some(id);
|
||||
assert_eq!(docs.source(cx), Some(source), "the template stays after a preview");
|
||||
assert!(docs.merge.read(cx).previews.contains(&id), "the preview is known");
|
||||
})
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, preview.clone(), None, window, cx)
|
||||
});
|
||||
@@ -227,7 +235,7 @@ fn two_generations_keep_their_own_templates() {
|
||||
for text in ["SourceA ", "SourceB "] {
|
||||
sources.push(blank(&mut cx, &docs));
|
||||
type_into(&mut cx, &docs, text);
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
|
||||
settled(&cx, &docs);
|
||||
}
|
||||
let csv = dir.path().join("one.csv");
|
||||
@@ -265,3 +273,40 @@ fn two_generations_keep_their_own_templates() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_template_chosen_with_the_data_stays_through_a_preview() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let template = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "Dear ");
|
||||
let csv = dir.path().join("two.csv");
|
||||
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.choose_csv(cx)));
|
||||
cx.simulate_path_prompt_response(move |_| Some(vec![csv]));
|
||||
settle(&cx, |app| docs.read(app).merge.read(app).table.is_some());
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
let table = cx.update(|app| docs.read(app).merge.read(app).table.clone().expect("the data"));
|
||||
// As the panel's buttons do: preview, then generate.
|
||||
cx.update(|app| docs.update(app, |docs, cx| {
|
||||
let source = docs.source(cx).expect("the template");
|
||||
docs.preview_record(source, &table, 0, cx);
|
||||
}));
|
||||
settle(&cx, |app| tabs(app, &docs) == 2 && docs.read(app).core.active != Some(template));
|
||||
let out = dir.path().join("out");
|
||||
std::fs::create_dir(&out).expect("the folder");
|
||||
cx.update(|app| docs.update(app, |docs, cx| {
|
||||
let source = docs.source(cx).expect("the template, not the preview");
|
||||
assert_eq!(source, template);
|
||||
docs.generate(source, table.clone(), false, out.clone(), cx);
|
||||
}));
|
||||
let stem = cx.update(|app| docs.read(app).tab(template).expect("a tab").name.to_string());
|
||||
let target = |n: usize| out.join(format!("{stem} {n}.docx"));
|
||||
settle(&cx, |_| target(2).exists());
|
||||
let (first, second) = (text_of(&mut cx, &docs, &target(1)), text_of(&mut cx, &docs, &target(2)));
|
||||
assert!(first.contains("Dear Ada") && second.contains("Dear Grace"), "{first:?} {second:?}");
|
||||
}
|
||||
@@ -463,3 +463,12 @@ fn an_outside_change_asks_and_keep_mine_overwrites() {
|
||||
mod find;
|
||||
mod more;
|
||||
mod overlap;
|
||||
|
||||
impl Docs {
|
||||
/// Chooses the active tab as template, then types a field.
|
||||
pub(crate) fn field_here(&mut self, header: &str, cx: &mut gpui::Context<Self>) {
|
||||
let active = self.core.active;
|
||||
self.merge.update(cx, |merge, _| merge.source = active);
|
||||
self.insert_field(header, cx);
|
||||
}
|
||||
}
|
||||
@@ -86,10 +86,10 @@ fn interleaved_previews_keep_their_sources() {
|
||||
};
|
||||
let alpha = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceAlpha ");
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
|
||||
let beta = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceBeta ");
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
|
||||
settled(&cx, &docs);
|
||||
let csv = dir.path().join("two.csv");
|
||||
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
|
||||
|
||||
@@ -45,6 +45,9 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("doc.merge.open", "Mail Merge…"),
|
||||
("doc.merge.title", "Mail Merge"),
|
||||
("doc.merge.data", "Data"),
|
||||
("doc.merge.template", "Template"),
|
||||
("doc.merge.gone", "The template is closed. Choose the data again."),
|
||||
("doc.merge.from_preview", "A preview cannot be a template. Switch to the original document."),
|
||||
("doc.merge.choose", "Choose CSV…"),
|
||||
("doc.merge.fields", "Fields"),
|
||||
("doc.merge.record", "Record"),
|
||||
@@ -104,6 +107,9 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("doc.merge.open", "邮件合并…"),
|
||||
("doc.merge.title", "邮件合并"),
|
||||
("doc.merge.data", "数据"),
|
||||
("doc.merge.template", "模板"),
|
||||
("doc.merge.gone", "模板已关闭,请重新选择数据。"),
|
||||
("doc.merge.from_preview", "预览不能作模板,请切回原文档。"),
|
||||
("doc.merge.choose", "选择 CSV…"),
|
||||
("doc.merge.fields", "字段"),
|
||||
("doc.merge.record", "记录"),
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
## 0.1.2 先做(用户 2026-10-09 定:先修致命与高,加自动更新,发布 0.1.2;其余随后按清单次序)
|
||||
|
||||
次序:S-03、P-01、X-01、D-01、D-02、P-02、P-03、P-04、M-01、X-02、X-03、K-01(Linux、Windows,本机不能运行)、UPD-1、REL-1、REL-2。T-05 已改一半(审查查出缩放后尺寸不重测),暂放,补丁存在草稿区。
|
||||
次序:S-03、P-01、X-01、D-01、D-02、P-02、P-03、P-04、M-01、X-02、X-03、K-01(Linux、Windows,本机不能运行)、UPD-1、REL-1、REL-2。K-01 不进 0.1.2:只影响 Linux 与 Windows 的 worker,本机不能运行验证,0.1.2 只发 macOS;留在下面的清单里,未勾。T-05 已改一半(审查查出缩放后尺寸不重测),暂放,补丁存在草稿区。
|
||||
|
||||
- [ ] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
- [ ] P-13 优化导出的“之前”大小取磁盘文件而非当前文档
|
||||
- [ ] P-14 文字块含控制字符时 JSON 回复不可解析
|
||||
- [x] D-01 导出或打印对话框开着时关闭其标签即崩溃 — Docs 实现 tab_closed,关标签即撤下其导出对话框;R1 PASS
|
||||
- [ ] D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录
|
||||
- [x] D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录 — 选数据时记下模板标签,预览副本不作模板;R1 测试绕过选 CSV 入口;R2 PASS
|
||||
- [ ] D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名)
|
||||
- [ ] D-04 一条批注的时间读不出,全部批注与修订都不显示
|
||||
- [ ] D-05 邮件合并某条记录失败时内核文档泄漏
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-doc/src/merge/tests.rs:174(相关:apps/ely-doc/src/tests.rs:471):模板绑定的回归测试绕过了生产入口 `choose_csv`。`field_here` 直接写入 `Merge.source`,随后测试直接向 `preview_record`、`generate` 传入模板编号;新增的 `source()` 断言验证的是辅助函数预写入的状态。具体复现:在隔离副本中仅移除 merge.rs:84 的 `merge.source = Some(source)`,现有 5 项 `merge::` 测试仍全部通过。此时新会话选择 CSV 后 `Merge.source` 保持 `None`,预览与生成都会误报“模板已关闭”。通过 `choose_csv` 和 `simulate_path_prompt_response` 驱动的额外探针在原代码通过,在该变异下明确失败:实际 `None`,预期 `Some(0)`。证据见 `/tmp/ely-d02-mutation-existing.log`、`/tmp/ely-d02-mutation-probe.log`、`/tmp/ely-d02-probe-baseline.log`;原代码的 ElyDoc 全部 47 项测试、5 项 i18n 测试及应用构建通过。修复方向:增加通过 CSV 选择入口建立绑定的回归测试,预览后经生产模板解析路径生成至少两条不同记录,核对来源与输出内容,并让上述删除绑定的变异触发失败。
|
||||
@@ -0,0 +1 @@
|
||||
PASS
|
||||
Reference in new issue
Block a user