Files
msime-windows/server/tests/src/test_caret_state_indicator_policy.cpp
T
fanlusky b3b3b3303c feat(windows): 焦点切换到另一个输入框时提示当前中英文状态
新增开关 general.caret_state_indicator_on_focus(默认关闭,需同时开启光标
状态提示)。

- DLL 把以下情况视为“换了输入框”:焦点窗口变化(Win32 编辑框、对话框、
  其他应用各有自己的窗口);焦点经过一段没有可编辑文档的间隙后落到可
  编辑文档上(Chromium、WPF 等单窗口宿主在字段之间就是先失焦再聚焦);
  焦点会话真正结束后重新开始。Chromium 打字时在两个可编辑文档之间直接
  替换文档管理器,不会触发。从 Win+. 面板返回不计入;线程焦点在真正的
  应用切换后返回计入。一次切换的多次回调由定时器合并为一次提示;组字
  过程中、以及没有光标的控件都保持静默。每次判断在 TSF 诊断日志里记录
  判定标志,不含任何文本
- 协议复用 IMESwitch:wch 改为 FanyImeCaretStateTrigger(UserToggle /
  CapsLockEdge / FocusEntered),CapsLockEdge 保持首发时的 VK_CAPITAL
  取值,Server 对未知值按 UserToggle 处理
- Server 在开关打开时显示当前的有效模式(Caps Lock 开启时为“英”);
  配置项同步写入两份模板和两个设置窗口宿主的读写映射
- 设置页在光标状态提示卡片中增加“切换输入框时提示”开关
2026-09-26 17:09:34 +08:00

235 lines
12 KiB
C++

#include "tests/includes/test_framework.h"
#include "engine/contracts/ipc_negotiation.h"
#include "engine/contracts/windows_ipc.h"
#include "window/caret_state_indicator_policy.h"
TEST_CASE(caret_state_indicator_visibility_is_independent_of_floating_toolbar)
{
REQUIRE(FanyImeUi::ShouldShowCaretStateIndicator(true, true, false, 50, 100));
REQUIRE(!FanyImeUi::ShouldShowCaretStateIndicator(false, true, false, 50, 100));
REQUIRE(!FanyImeUi::ShouldShowCaretStateIndicator(true, false, false, 50, 100));
REQUIRE(!FanyImeUi::ShouldShowCaretStateIndicator(true, true, false, 0, 0));
REQUIRE(!FanyImeUi::ShouldShowCaretStateIndicator(true, true, false, 50, -10000));
REQUIRE(!FanyImeUi::ShouldShowCaretStateIndicator(true, true, false, 50, -100000));
}
TEST_CASE(caret_state_indicator_never_shows_for_uiless_hosts)
{
// A UILess host (game) draws its own UI; the badge must not create a
// topmost popup over it even with a valid anchor and the feature on.
REQUIRE(!FanyImeUi::ShouldShowCaretStateIndicator(true, true, true, 50, 100));
}
TEST_CASE(caret_state_indicator_rejects_unresolved_anchor_sentinel)
{
// The DLL writes {0, INVALID_Y} when it could not resolve the caret.
REQUIRE(!FanyImeUi::IsUsableCaretAnchor(0, -100000)); // Global::INVALID_Y
REQUIRE(!FanyImeUi::IsUsableCaretAnchor(0, 0));
REQUIRE(FanyImeUi::IsUsableCaretAnchor(0, 1));
REQUIRE(FanyImeUi::IsUsableCaretAnchor(-1200, 300)); // monitor left of primary
}
TEST_CASE(caret_state_indicator_upper_positions_clear_the_caret_line)
{
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorY(false, 200, 30, 24, 6), 140);
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorY(false, 400, 60, 48, 12), 280);
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorY(true, 200, 30, 24, 6), 206);
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorY(true, 200, 30, 200, 6), 206);
}
TEST_CASE(caret_state_indicator_flips_away_from_work_area_and_suppresses_when_neither_side_fits)
{
using FanyImeUi::CaretStateIndicatorPlacementY;
REQUIRE_EQ(*CaretStateIndicatorPlacementY(true, 170, 30, 24, 6, 0, 200), 110);
REQUIRE_EQ(*CaretStateIndicatorPlacementY(false, 30, 30, 24, 6, 0, 200), 36);
REQUIRE(!CaretStateIndicatorPlacementY(true, 50, 80, 24, 6, 0, 100));
REQUIRE(!CaretStateIndicatorPlacementY(false, 50, 80, 24, 6, 0, 100));
}
TEST_CASE(caret_state_indicator_horizontal_positions_follow_badge_width)
{
REQUIRE_EQ(FanyImeUi::kCaretStatePunctuationBadgeWidthDip, 94);
constexpr int punctuationBadgeWidth = FanyImeUi::kCaretStatePunctuationBadgeWidthDip;
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorX("top-left", 200, punctuationBadgeWidth, 6), 100);
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorX("top", 200, punctuationBadgeWidth, 6), 153);
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorX("top-right", 200, punctuationBadgeWidth, 6), 206);
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorX("bottom-left", 200, punctuationBadgeWidth, 6), 100);
// "bottom" is 正下方: centred like "top", matching the settings preview.
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorX("bottom", 200, punctuationBadgeWidth, 6), 153);
REQUIRE_EQ(FanyImeUi::CaretStateIndicatorX("bottom-right", 200, punctuationBadgeWidth, 6), 206);
}
TEST_CASE(caret_state_indicator_positions_pair_a_side_with_an_alignment)
{
using FanyImeUi::IsBelowCaretPosition;
using FanyImeUi::IsValidCaretStatePosition;
for (const std::string position : {"top-left", "top", "top-right", "bottom-left", "bottom", "bottom-right"})
{
REQUIRE(IsValidCaretStatePosition(position));
REQUIRE_EQ(IsBelowCaretPosition(position), position.rfind("bottom", 0) == 0);
}
REQUIRE(IsValidCaretStatePosition(FanyImeUi::kDefaultCaretStatePosition));
REQUIRE(!IsValidCaretStatePosition(""));
REQUIRE(!IsValidCaretStatePosition("left"));
REQUIRE(!IsValidCaretStatePosition("Bottom"));
REQUIRE(!IsBelowCaretPosition("top"));
}
TEST_CASE(caret_state_indicator_badge_width_follows_structure_not_string_length)
{
using FanyImeUi::CaretStateBadgeWidthDip;
REQUIRE_EQ(CaretStateBadgeWidthDip({L"中"}), 30);
REQUIRE_EQ(CaretStateBadgeWidthDip({L"全"}), 30);
REQUIRE_EQ(CaretStateBadgeWidthDip({L"abc"}), 70);
REQUIRE_EQ(CaretStateBadgeWidthDip(FanyImeUi::PunctuationBadge(true, true, false)), 94);
REQUIRE_EQ(CaretStateBadgeWidthDip(FanyImeUi::PunctuationBadge(false, false, false)), 94);
}
TEST_CASE(caret_state_indicator_maps_input_mode_to_one_glyph)
{
using FanyImeUi::EffectiveInputModeGlyph;
using FanyImeUi::InputModeGlyph;
REQUIRE_EQ(InputModeGlyph(true, false), L'中');
REQUIRE_EQ(InputModeGlyph(false, false), L'英');
REQUIRE_EQ(InputModeGlyph(true, true), L'日');
REQUIRE_EQ(EffectiveInputModeGlyph(true, false, true), L'英');
REQUIRE_EQ(EffectiveInputModeGlyph(true, false, false), L'中');
REQUIRE_EQ(EffectiveInputModeGlyph(true, true, false), L'日');
}
TEST_CASE(caret_state_indicator_uses_effective_mode_for_caps_and_language_actions)
{
struct ModeCase
{
bool imeEnabled;
bool japaneseMode;
wchar_t authoritativeGlyph;
};
constexpr ModeCase modes[] = {
{false, false, L'英'},
{true, false, L'中'},
{true, true, L'日'},
};
using FanyImeUi::InputModeTrigger;
using FanyImeUi::ShouldShowInputModeEvent;
for (const auto &mode : modes)
{
for (const bool capsEnabled : {false, true})
{
// The focus-announcement setting never affects the other triggers.
for (const bool onFocus : {false, true})
{
const bool capsEdgeShouldShow = mode.authoritativeGlyph != L'英';
REQUIRE_EQ(ShouldShowInputModeEvent(InputModeTrigger::CapsLockEdge, onFocus, capsEnabled,
mode.imeEnabled, mode.japaneseMode),
capsEdgeShouldShow);
REQUIRE_EQ(ShouldShowInputModeEvent(InputModeTrigger::UserToggle, onFocus, capsEnabled, mode.imeEnabled,
mode.japaneseMode),
!capsEnabled);
}
const auto badge = FanyImeUi::InputModeBadge(mode.imeEnabled, mode.japaneseMode, capsEnabled);
REQUIRE(!badge.HasModeSlot());
REQUIRE(badge.text == std::wstring(1, capsEnabled ? L'英' : mode.authoritativeGlyph));
}
}
}
TEST_CASE(caret_state_indicator_focus_announcement_is_opt_in_and_shows_the_effective_mode)
{
using FanyImeUi::InputModeTrigger;
using FanyImeUi::ShouldShowInputModeEvent;
for (const bool imeEnabled : {false, true})
{
for (const bool capsEnabled : {false, true})
{
// Unlike a toggle, entering a field reports the mode as it is,
// Caps Lock included, but only when the user asked for it.
REQUIRE(ShouldShowInputModeEvent(InputModeTrigger::FocusEntered, true, capsEnabled, imeEnabled, false));
REQUIRE(!ShouldShowInputModeEvent(InputModeTrigger::FocusEntered, false, capsEnabled, imeEnabled, false));
}
}
REQUIRE(FanyImeUi::InputModeBadge(true, false, true).text == L"英");
REQUIRE(FanyImeUi::InputModeBadge(true, true, false).text == L"日");
}
TEST_CASE(caret_state_indicator_decodes_the_ime_switch_trigger)
{
using FanyImeUi::DecodeInputModeTrigger;
using FanyImeUi::InputModeTrigger;
REQUIRE(DecodeInputModeTrigger(FanyImeCaretStateTrigger::UserToggle) == InputModeTrigger::UserToggle);
REQUIRE(DecodeInputModeTrigger(FanyImeCaretStateTrigger::CapsLockEdge) == InputModeTrigger::CapsLockEdge);
REQUIRE(DecodeInputModeTrigger(FanyImeCaretStateTrigger::FocusEntered) == InputModeTrigger::FocusEntered);
// A value from a newer client degrades to an ordinary toggle.
REQUIRE(DecodeInputModeTrigger(0x7F) == InputModeTrigger::UserToggle);
}
TEST_CASE(caret_state_indicator_uses_the_ime_switch_packet_caps_snapshot)
{
const auto capsOnPacket = FanyImePipeFlags::EncodeImeSwitchCapsLockSnapshot(true);
const auto capsOffPacket = FanyImePipeFlags::EncodeImeSwitchCapsLockSnapshot(false);
// Later global state has already moved to Caps off, but this ordinary
// language event happened while Caps was on and must remain suppressed.
const auto ordinaryPacketCapsState = FanyImePipeFlags::DecodeImeSwitchCapsLockSnapshot(capsOnPacket);
REQUIRE(ordinaryPacketCapsState.has_value());
REQUIRE(!FanyImeUi::ShouldShowInputModeEvent(FanyImeUi::InputModeTrigger::UserToggle, false,
*ordinaryPacketCapsState, true, false));
// Conversely, a queued Caps-off edge restores the authoritative Chinese
// glyph even if a newer keydown has already turned the global state on.
const auto capsEdgePacketState = FanyImePipeFlags::DecodeImeSwitchCapsLockSnapshot(capsOffPacket);
REQUIRE(capsEdgePacketState.has_value());
REQUIRE(FanyImeUi::ShouldShowInputModeEvent(FanyImeUi::InputModeTrigger::CapsLockEdge, false, *capsEdgePacketState,
true, false));
REQUIRE(FanyImeUi::InputModeBadge(true, false, *capsEdgePacketState).text == L"中");
// A packet without the Present bit is not an authoritative snapshot.
REQUIRE(!FanyImePipeFlags::DecodeImeSwitchCapsLockSnapshot(0).has_value());
REQUIRE(
!FanyImePipeFlags::DecodeImeSwitchCapsLockSnapshot(FanyImePipeFlags::ImeSwitchCapsSnapshotEnabled).has_value());
// The snapshot bits never collide with the UILess routing flag.
REQUIRE_EQ(capsOnPacket & FanyImePipeFlags::UiLess, 0u);
}
TEST_CASE(caret_state_indicator_combines_punctuation_and_input_mode)
{
using FanyImeUi::PunctuationBadge;
REQUIRE(PunctuationBadge(true, true, false) == (FanyImeUi::CaretStateBadge{L",。", L'中'}));
REQUIRE(PunctuationBadge(false, true, false) == (FanyImeUi::CaretStateBadge{L",.", L'中'}));
REQUIRE(PunctuationBadge(true, false, false) == (FanyImeUi::CaretStateBadge{L",。", L'英'}));
REQUIRE(PunctuationBadge(false, false, false) == (FanyImeUi::CaretStateBadge{L",.", L'英'}));
REQUIRE(PunctuationBadge(true, true, true) == (FanyImeUi::CaretStateBadge{L",。", L'日'}));
REQUIRE(PunctuationBadge(false, true, true) == (FanyImeUi::CaretStateBadge{L",.", L'日'}));
REQUIRE(PunctuationBadge(true, true, false).HasModeSlot());
}
TEST_CASE(caret_state_indicator_maps_single_state_switches_to_one_glyph)
{
using FanyImeUi::CaretStateKind;
using FanyImeUi::SingleStateBadge;
REQUIRE(SingleStateBadge(CaretStateKind::Width, true).text == L"全");
REQUIRE(SingleStateBadge(CaretStateKind::Width, false).text == L"半");
REQUIRE(SingleStateBadge(CaretStateKind::CharacterSet, false).text == L"简");
REQUIRE(SingleStateBadge(CaretStateKind::CharacterSet, true).text == L"繁");
REQUIRE(!SingleStateBadge(CaretStateKind::Width, true).HasModeSlot());
}
TEST_CASE(caret_state_indicator_capability_is_optional_and_negotiated)
{
// A versioned client that does not advertise the bit must not get it, so
// it never sends badge-only events that an old Server would misapply.
const auto withoutBadge = FanyImeProtocol::Hello(7, 19, FanyImeProtocol::Capabilities);
const auto negotiatedWithout =
FanyImeProtocol::Negotiate(withoutBadge, FanyImeProtocol::Capabilities | FanyImeProtocol::CaretStateIndicator);
REQUIRE(negotiatedWithout.accepted);
REQUIRE_EQ(negotiatedWithout.capabilities & FanyImeProtocol::CaretStateIndicator, 0u);
const auto withBadge =
FanyImeProtocol::Hello(7, 20, FanyImeProtocol::Capabilities | FanyImeProtocol::CaretStateIndicator);
REQUIRE((FanyImeProtocol::Negotiate(withBadge).capabilities & FanyImeProtocol::CaretStateIndicator) == 0u);
REQUIRE((FanyImeProtocol::Negotiate(withBadge, FanyImeProtocol::Capabilities | FanyImeProtocol::CaretStateIndicator)
.capabilities &
FanyImeProtocol::CaretStateIndicator) != 0u);
}