Files
qingjian/apps/windows/server/tests/engine_loop/composing.rs
T
yenharvey 61ca4b4150 feat: 符号面板加「翻译」组,删掉 Windows 的整句候选翻译
- 整句候选翻译译的是猜出来的候选,猜错就白发;改成用户在面板的框里自己打一句、停键后再译,整句只发确认过的内容
- 翻译组排在最前,每种目标语言算一个分类([ ] 换),打拼音时按 ↑ 直接落在这组;没在打字时 [shortcut] translate_panel(Ctrl+Alt+Y / ⌃⌥Y)打开
- Core panel/translate:原文带光标,停键 800 ms、两个字以上才发,内容没变不重发,没翻出来 2 秒后自动重试一次、再失败等回车;译文按原文 + 语言缓存在内存(换提供方清空),不落盘
- 译文放进应用的 preedit:Windows 译文靠轮询送到,DLL 记着写进文档的组句文字,变了就补一次编辑会话;行内不显示拼音的设置下也照样放
- 框的排版抽成 bamboo-slip 的 TextFlow,与设置程序的 TextField 共用;面板不是 Ui 搭的,布局后按各字位置给出 PanelHit::Caret
- [predict] sentence_translation 不再认(旧配置里写着不报错),两个设置程序的开关与文档一并去掉;生词本整句翻译保留
- Windows dispatch/composed/mod.rs 超了 800 行,按联想、导航、出帧拆开
- 初音主题当前分组胶囊改纯色
2026-11-04 16:18:39 +08:00

397 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 中文组句:出候选、选词、上屏、翻页键、直输段、失焦与学习落盘。
use crate::support::*;
#[test]
fn typing_pinyin_shows_candidates() {
let mut router = router();
let (outcome, commit, frame) = type_letters(&mut router, "nihao");
assert_eq!(outcome, KeyOutcome::Consumed);
assert_eq!(commit, None);
assert_eq!(preedit(&frame), "ni'hao");
let texts: Vec<&str> = frame
.candidates
.items
.iter()
.map(|c| c.text.as_str())
.collect();
assert!(
texts.contains(&"你好"),
"候选里应有「你好」,实际:{texts:?}"
);
}
/// 拼音显示位置随帧下发给 DLL:DLL 按 `inline()` 决定要不要往应用里放行内拼音,
/// 窗口顶部画不画拼音行由 Server 自己按 `in_window()` 定,所以帧始终带着拼音行。
#[test]
fn frame_carries_the_preedit_mode() {
for mode in PreeditMode::ALL {
let mut router = router_with(RouterConfig {
preedit: mode,
..RouterConfig::default()
});
let (_, _, frame) = type_letters(&mut router, "nihao");
assert_eq!(frame.preedit_mode, mode);
assert_eq!(
preedit(&frame),
"ni'hao",
"{mode:?} 下帧也要带拼音行,画不画是窗口的事"
);
}
}
#[test]
fn selecting_by_digit_commits_and_clears() {
let mut router = router();
let (_, _, frame) = type_letters(&mut router, "nihao");
let position = frame
.candidates
.items
.iter()
.position(|c| c.text == "你好")
.expect("「你好」在候选页内");
let (outcome, commit, after) = key_result(router.handle(ClientMessage::Key {
session: SESSION,
event: digit(position as u32 + 1),
}));
assert_eq!(outcome, KeyOutcome::Consumed);
assert_eq!(commit.as_deref(), Some("你好"));
assert!(
after.is_empty(),
"上屏后应收起候选,实际 preedit={:?}",
preedit(&after)
);
}
#[test]
fn space_commits_first_candidate() {
let mut router = router();
type_letters(&mut router, "ni");
let space = KeyEvent::new(0x20, Some(' '), Default::default());
let (outcome, commit, after) = key_result(router.handle(ClientMessage::Key {
session: SESSION,
event: space,
}));
assert_eq!(outcome, KeyOutcome::Consumed);
assert_eq!(commit.as_deref(), Some("你"), "「ni」首选应是「你」");
assert!(after.is_empty());
}
#[test]
fn backspace_shrinks_preedit() {
let mut router = router();
let (_, _, frame) = type_letters(&mut router, "nihao");
assert_eq!(preedit(&frame), "ni'hao");
let back = KeyEvent::new(0x08, None, Default::default());
let (outcome, _, after) = key_result(router.handle(ClientMessage::Key {
session: SESSION,
event: back,
}));
assert_eq!(outcome, KeyOutcome::Consumed);
assert_eq!(preedit(&after), "ni'ha");
}
#[test]
fn non_letter_without_composing_passes_through() {
let mut router = router();
let space = KeyEvent::new(0x20, Some(' '), Default::default());
let (outcome, commit, frame) = key_result(router.handle(ClientMessage::Key {
session: SESSION,
event: space,
}));
assert_eq!(outcome, KeyOutcome::Passthrough);
assert_eq!(commit, None);
assert!(frame.is_empty());
}
#[test]
fn focus_leave_commits_raw_pinyin() {
let mut router = router();
type_letters(&mut router, "nihao");
let committed = router.handle(ClientMessage::Commit { session: SESSION });
assert_eq!(
committed,
Some(ServerMessage::Committed {
session: SESSION,
text: Some("nihao".to_owned()),
})
);
let (_, _, frame) = type_letters(&mut router, "ni");
assert_eq!(preedit(&frame), "ni");
// 没在组句时 Commit 不交东西。
router.handle(ClientMessage::Key {
session: SESSION,
event: KeyEvent::new(0x1B, None, Default::default()),
});
assert_eq!(
router.handle(ClientMessage::Commit { session: SESSION }),
Some(ServerMessage::Committed {
session: SESSION,
text: None,
})
);
}
#[test]
fn commit_from_other_session_does_not_take_buffer() {
let mut router = router();
type_letters(&mut router, "ni");
let other = SessionId(2);
router.handle(ClientMessage::OpenSession {
session: other,
app: None,
protocol: PROTOCOL_VERSION,
});
// 别的会话拿不到这个会话的拼音,但残留组句一并清掉。
assert_eq!(
router.handle(ClientMessage::Commit { session: other }),
Some(ServerMessage::Committed {
session: other,
text: None,
})
);
let space = KeyEvent::new(0x20, Some(' '), Default::default());
let (outcome, _, _) = key_result(router.handle(ClientMessage::Key {
session: SESSION,
event: space,
}));
assert_eq!(outcome, KeyOutcome::Passthrough);
}
#[test]
fn page_keys_follow_config() {
// 每页 1 条保证多页;翻页键改成 `,` `.`。
// 组句中标点进直输段的老行为(`punct_commits` 关),`]` 才会留在拼音行里
let mut router = router_with(RouterConfig {
page_size: 1,
page_keys: (',', '.'),
punct_commits: false,
..RouterConfig::default()
});
let (_, _, frame) = type_letters(&mut router, "ni");
assert!(frame.page_count > 1, "样例词库里 ni 应不止一个候选");
assert_eq!(frame.page, 0);
let key = |router: &mut Router, c| {
key_result(router.handle(ClientMessage::Key {
session: SESSION,
event: punct(c),
}))
};
let (outcome, commit, frame) = key(&mut router, '.');
assert_eq!((outcome, commit), (KeyOutcome::Consumed, None));
assert_eq!(frame.page, 1, "`.` 应翻到下一页");
let (_, _, frame) = key(&mut router, ',');
assert_eq!(frame.page, 0, "`,` 应翻回上一页");
// 缺省的 `]` 此时不再翻页,进直输段。
let (_, _, frame) = key(&mut router, ']');
assert_eq!(frame.page, 0);
assert!(
preedit(&frame).contains(']'),
"`]` 应进直输段:{}",
preedit(&frame)
);
}
#[test]
fn minus_equals_page_keys_preserve_expression_input() {
let mut router = router_with(RouterConfig {
page_size: 1,
page_keys: ('-', '='),
..RouterConfig::default()
});
let (_, _, frame) = type_letters(&mut router, "ni");
assert!(frame.page_count > 1);
let (outcome, commit, frame) = press(&mut router, punct('='));
assert_eq!((outcome, commit), (KeyOutcome::Consumed, None));
assert_eq!(frame.page, 1);
assert_eq!(preedit(&frame), "ni");
let (_, _, frame) = press(&mut router, punct('-'));
assert_eq!(frame.page, 0);
assert_eq!(preedit(&frame), "ni");
press(&mut router, KeyEvent::new(0x1B, None, Default::default()));
type_letters(&mut router, "v");
for c in "2-1=".chars() {
let (outcome, commit, _) = press(&mut router, punct(c));
assert_eq!((outcome, commit), (KeyOutcome::Consumed, None));
}
let (outcome, commit, _) = press(&mut router, punct(' '));
assert_eq!(
(outcome, commit),
(KeyOutcome::Consumed, Some("2-1=1".to_owned()))
);
}
#[test]
fn shift_uppercase_while_composing_commits_raw_first() {
let mut router = router();
type_letters(&mut router, "ni");
// 中文模式按住 Shift 打大写字母:拼音原样上屏,字母跟在后面一起插。
let shifted = KeyModifiers {
shift: true,
..KeyModifiers::default()
};
let (outcome, commit, frame) = press(&mut router, letter_with('A', shifted));
assert_eq!(
(outcome, commit.as_deref()),
(KeyOutcome::Consumed, Some("niA"))
);
assert!(frame.is_empty());
// 没在组句时大写字母交给应用。
let (outcome, commit, _) = press(&mut router, letter_with('A', shifted));
assert_eq!((outcome, commit), (KeyOutcome::Passthrough, None));
}
#[test]
fn learning_data_persists_to_user_dir() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..");
let user_dir =
std::env::temp_dir().join(format!("qingjian-windows-learning-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&user_dir);
std::fs::create_dir_all(&user_dir).unwrap();
let engine = assembly::assemble(&AssemblySpec {
glossary: Some((
Language::English,
root.join("assets/sample/glossary-en.tsv"),
)),
user_dir: Some(user_dir.clone()),
..AssemblySpec::new(root.join("assets/sample/dict.tsv"))
})
.unwrap();
let mut router = Router::new(engine, RouterConfig::default());
router.handle(ClientMessage::OpenSession {
session: SESSION,
app: None,
protocol: PROTOCOL_VERSION,
});
let (_, _, frame) = type_letters(&mut router, "nihao");
let position = frame
.candidates
.items
.iter()
.position(|c| c.text == "你好")
.unwrap();
router.handle(ClientMessage::Key {
session: SESSION,
event: digit(position as u32 + 1),
});
// 关会话时落盘。
router.handle(ClientMessage::CloseSession { session: SESSION });
let user = std::fs::read_to_string(user_dir.join("user.tsv")).expect("user.tsv 应已写出");
assert!(user.contains("你好"), "user.tsv 里应记了「你好」:{user}");
assert!(
user_dir.join(qingjian_learning::USAGE_FILE).is_file(),
"上屏时就该追加进输入统计"
);
let _ = std::fs::remove_dir_all(&user_dir);
}
#[test]
fn raw_segment_takes_digits_and_keeps_the_space() {
// `-` 进英文直输段之后数字是内容不是选词键,空格整段原样上屏并保留空格(#28 排查时发现 `gpt-6` 丢了 6)。
let mut router = router();
let mut frame = Frame::default();
for c in "gpt-6".chars() {
let (outcome, commit, next) = press(&mut router, letter(c));
assert_eq!((outcome, commit), (KeyOutcome::Consumed, None));
frame = next;
}
assert_eq!(preedit(&frame), "gpt-6");
let (outcome, commit, after) = press(&mut router, letter(' '));
assert_eq!(
(outcome, commit.as_deref()),
(KeyOutcome::Consumed, Some("gpt-6 "))
);
assert!(after.is_empty());
}
#[test]
fn digit_without_a_slot_joins_the_buffer() {
// 这一页没有第 9 格:数字是内容(`gpt9`),不再被静默吞掉;成了直输段之后空格整段上屏。
let mut router = router();
let (_, _, frame) = type_letters(&mut router, "gpt");
let shown = frame.candidates.items.len();
assert!(
(1..9).contains(&shown),
"样例词库下 gpt 的候选应不满 9 个:{shown}"
);
let (outcome, commit, frame) = press(&mut router, digit(9));
assert_eq!((outcome, commit), (KeyOutcome::Consumed, None));
assert_eq!(preedit(&frame), "gpt9");
let (_, commit, after) = press(&mut router, letter(' '));
assert_eq!(commit.as_deref(), Some("gpt9 "));
assert!(after.is_empty());
// 有这一格照常选词。
let (_, _, frame) = type_letters(&mut router, "ni");
let first = frame.candidates.items[0].text.clone();
let (_, commit, _) = press(&mut router, digit(1));
assert_eq!(commit, Some(first));
}
/// 双拼「输入框显示原始按键」:发给 DLL 的帧是敲的键、光标按键数算,自绘窗的拼音行照旧全拼。
#[test]
fn shuangpin_raw_preedit_goes_to_the_app_and_full_pinyin_to_the_window() {
let mut router = router_with(RouterConfig {
scheme: Scheme::Shuangpin(ShuangpinScheme::Xiaohe),
..RouterConfig::default()
});
router.engine_mut().set_shuangpin_raw_preedit(true);
let sink = RecordingCandidates::default();
router.set_candidate_sink(Box::new(sink.clone()));
type_letters(&mut router, "kdfa");
let _ = router.handle(ClientMessage::PositionCandidates {
session: SESSION,
rect: rect(),
});
press(&mut router, function_key(0x25));
let (_, _, frame) = press(&mut router, function_key(0x25));
assert_eq!((preedit(&frame).as_str(), frame.cursor), ("kdfa", 2));
let shown = sink.0.lock().unwrap();
let last = shown.last().expect("自绘窗收到过帧");
assert_eq!((preedit(last).as_str(), last.cursor), ("kai'fa", 3));
}
/// 自绘窗随帧收到输入状态(给主题显示中 / 英、简 / 繁、方案),与配置一致。
#[test]
fn self_drawn_window_gets_input_mode() {
let mut router = router_with(RouterConfig {
scheme: Scheme::Shuangpin(ShuangpinScheme::Xiaohe),
traditional: true,
full_width: true,
..RouterConfig::default()
});
let sink = RecordingCandidates::default();
router.set_candidate_sink(Box::new(sink.clone()));
type_letters(&mut router, "ni");
let _ = router.handle(ClientMessage::PositionCandidates {
session: SESSION,
rect: rect(),
});
let modes = sink.1.lock().unwrap();
let mode = modes.last().expect("自绘窗收到过帧");
assert!(!mode.english && mode.traditional && mode.full_width);
assert_eq!(mode.scheme, "小鹤双拼");
}
#[test]
fn space_on_an_email_address_commits_it_without_the_space() {
let mut router = router();
type_letters(&mut router, "qingjian");
let (_, _, frame) = press(&mut router, punct('@'));
assert_eq!(
candidate_texts(&frame)[..2],
["qingjian@", "qingjian@qq.com"]
);
press(&mut router, function_key(0x28));
let (_, commit, after) = press(&mut router, letter(' '));
assert_eq!(commit.as_deref(), Some("qingjian@qq.com"));
assert!(after.is_empty());
}