Files
qingjian/apps/windows/server/tests/engine_loop/composing.rs
T
GH a3a0085913 feat: 组句中敲标点先上屏高亮候选再补标点 (#402)
中文模式组句时敲 , . ? ! 等会转全角的标点,先上屏高亮候选再补全角标点(nihao, → 你好,),与微信、搜狗一致;[general] punct_commits 开关,关掉是标点进英文直输段的老行为。
2026-10-09 21:13:16 +08:00

380 lines
13 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("usage.tsv").is_file(), "usage.tsv 应已写出");
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, "小鹤双拼");
}