feat(input): 完成阶段 1 输入采集与规范化
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
#include "CppUnitTest.h"
|
||||
#include "StellarX.h"
|
||||
#include "SxEventLoopInternal.h"
|
||||
#include "SxNativeInputInternal.h"
|
||||
#include "SxTextInternal.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
|
||||
@@ -312,6 +315,306 @@ namespace StellarXNativeTests
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 1 only validates capture-time snapshots, normalization, queue ordering,
|
||||
// and fail-stop. No test in this class performs focus or control routing.
|
||||
TEST_CLASS(NativeInputTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(KeyLParamAndModifierSnapshotArePreserved)
|
||||
{
|
||||
StellarX::Internal::KeyModifierSnapshot modifiers;
|
||||
modifiers.leftControl = true;
|
||||
modifiers.rightAlt = true;
|
||||
const LPARAM lParam = static_cast<LPARAM>(3u | (0x1Du << 16) |
|
||||
(1u << 24) | (1u << 29) | (1u << 30));
|
||||
const auto event = StellarX::Internal::makeKeyEvent(
|
||||
WM_SYSKEYDOWN, VK_CONTROL, lParam, modifiers, true);
|
||||
|
||||
Assert::IsTrue(event.action == StellarX::KeyAction::Down);
|
||||
Assert::AreEqual<UINT>(VK_CONTROL, event.virtualKey);
|
||||
Assert::AreEqual<UINT>(VK_RCONTROL, event.physicalVirtualKey);
|
||||
Assert::AreEqual<UINT>(0x1D, event.scanCode);
|
||||
Assert::AreEqual<std::uint16_t>(3, event.repeatCount);
|
||||
Assert::IsTrue(event.extended);
|
||||
Assert::IsTrue(event.wasDown);
|
||||
Assert::IsTrue(event.altContext);
|
||||
Assert::IsTrue(event.systemKey);
|
||||
Assert::IsTrue(event.imeComposingAtCapture);
|
||||
Assert::IsTrue(event.modifiers.leftControl);
|
||||
Assert::IsTrue(event.modifiers.rightAlt);
|
||||
Assert::IsTrue(event.isAutoRepeat());
|
||||
}
|
||||
|
||||
TEST_METHOD(ProcessKeyOwnsOnlyItsKeyAndDoesNotStartComposition)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.collect(nullptr, WM_KEYDOWN, VK_PROCESSKEY, 1);
|
||||
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* key = std::get_if<StellarX::KeyEvent>(&event.payload);
|
||||
Assert::IsNotNull(key);
|
||||
Assert::AreEqual<UINT>(VK_PROCESSKEY, key->virtualKey);
|
||||
Assert::IsFalse(key->imeComposingAtCapture);
|
||||
Assert::IsTrue(key->isImeOwnedAtCapture());
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
key = std::get_if<StellarX::KeyEvent>(&event.payload);
|
||||
Assert::IsNotNull(key);
|
||||
Assert::IsFalse(key->imeComposingAtCapture);
|
||||
Assert::IsFalse(key->isImeOwnedAtCapture());
|
||||
}
|
||||
|
||||
TEST_METHOD(CompositionLifecycleIsCapturedIndependentlyFromKeyOwnership)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.collect(nullptr, WM_IME_STARTCOMPOSITION, 0, 0);
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
context.collect(nullptr, WM_IME_ENDCOMPOSITION, 0, 0);
|
||||
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* start = std::get_if<StellarX::CompositionEvent>(&event.payload);
|
||||
Assert::IsNotNull(start);
|
||||
Assert::IsTrue(start->stage == StellarX::CompositionStage::Start);
|
||||
Assert::AreNotEqual<std::uint64_t>(0, start->compositionId);
|
||||
const std::uint64_t compositionId = start->compositionId;
|
||||
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* key = std::get_if<StellarX::KeyEvent>(&event.payload);
|
||||
Assert::IsNotNull(key);
|
||||
Assert::IsTrue(key->imeComposingAtCapture);
|
||||
Assert::IsFalse(key->isImeOwnedAtCapture());
|
||||
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* cancel = std::get_if<StellarX::CompositionEvent>(&event.payload);
|
||||
Assert::IsNotNull(cancel);
|
||||
Assert::IsTrue(cancel->stage == StellarX::CompositionStage::Cancel);
|
||||
Assert::AreEqual(compositionId, cancel->compositionId);
|
||||
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* end = std::get_if<StellarX::CompositionEvent>(&event.payload);
|
||||
Assert::IsNotNull(end);
|
||||
Assert::IsTrue(end->stage == StellarX::CompositionStage::End);
|
||||
Assert::AreEqual(compositionId, end->compositionId);
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(AnsiDbcsLeadAndTrailBecomeOneUtf16Event)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.setAnsiCodePageForTesting(936);
|
||||
context.collect(nullptr, WM_CHAR, 0xD6, 1);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
|
||||
context.collect(nullptr, WM_CHAR, 0xD0, 1);
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
|
||||
Assert::IsNotNull(text);
|
||||
Assert::AreEqual<std::size_t>(1, text->text.size());
|
||||
Assert::AreEqual<wchar_t>(static_cast<wchar_t>(0x4E2D), text->text[0]);
|
||||
Assert::IsTrue(text->source == StellarX::TextSource::AnsiCharFallback);
|
||||
Assert::AreEqual<std::uint64_t>(0, text->compositionId);
|
||||
}
|
||||
|
||||
TEST_METHOD(UnicodeScalarProducesSurrogatePairAndConsumesMessage)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
const auto query = context.collect(nullptr, WM_UNICHAR, UNICODE_NOCHAR, 0);
|
||||
Assert::IsTrue(query.handledSynchronously);
|
||||
Assert::AreEqual<LRESULT>(TRUE, query.result);
|
||||
|
||||
const auto actual = context.collect(nullptr, WM_UNICHAR, 0x1F600, 0);
|
||||
Assert::IsTrue(actual.handledSynchronously);
|
||||
Assert::AreEqual<LRESULT>(0, actual.result);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
|
||||
Assert::IsNotNull(text);
|
||||
Assert::AreEqual<std::size_t>(2, text->text.size());
|
||||
Assert::AreEqual<wchar_t>(static_cast<wchar_t>(0xD83D), text->text[0]);
|
||||
Assert::AreEqual<wchar_t>(static_cast<wchar_t>(0xDE00), text->text[1]);
|
||||
Assert::IsTrue(text->source == StellarX::TextSource::UnicodeScalar);
|
||||
}
|
||||
|
||||
TEST_METHOD(InvalidUnicodeScalarIsRejectedAndCounted)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
const auto result = context.collect(nullptr, WM_UNICHAR, 0x110000, 0);
|
||||
Assert::IsFalse(result.handledSynchronously);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
Assert::AreEqual<std::uint64_t>(1,
|
||||
context.normalizationStats().rejectedTextSequences);
|
||||
}
|
||||
|
||||
TEST_METHOD(ImeResultEchoChannelsAreSuppressedWithoutContentWideDedup)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.setAnsiCodePageForTesting(936);
|
||||
const std::wstring committed(1, static_cast<wchar_t>(0x4E2D));
|
||||
context.seedEchoForTesting(committed, 7);
|
||||
|
||||
context.collect(nullptr, WM_IME_CHAR, 0xD6D0, 0);
|
||||
context.collect(nullptr, WM_CHAR, 0xD6, 0);
|
||||
context.collect(nullptr, WM_CHAR, 0xD0, 0);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
|
||||
// A new physical key is an echo boundary. Equal later content is real input.
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
context.collect(nullptr, WM_CHAR, 0xD6, 0);
|
||||
context.collect(nullptr, WM_CHAR, 0xD0, 0);
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
Assert::IsNotNull(std::get_if<StellarX::KeyEvent>(&event.payload));
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
|
||||
Assert::IsNotNull(text);
|
||||
Assert::AreEqual(committed, text->text);
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(ImeEndDoesNotDiscardImmediatelyFollowingResultEcho)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.setAnsiCodePageForTesting(936);
|
||||
const std::wstring committed(1, static_cast<wchar_t>(0x4E2D));
|
||||
context.seedEchoForTesting(committed, 9);
|
||||
|
||||
context.collect(nullptr, WM_IME_ENDCOMPOSITION, 0, 0);
|
||||
context.collect(nullptr, WM_IME_CHAR, 0xD6D0, 0);
|
||||
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(UnrepresentableAnsiEchoDoesNotSuppressFollowingText)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.setAnsiCodePageForTesting(936);
|
||||
std::wstring committed;
|
||||
committed.push_back(static_cast<wchar_t>(0xD83D));
|
||||
committed.push_back(static_cast<wchar_t>(0xDE00));
|
||||
|
||||
BOOL usedDefault = FALSE;
|
||||
const int required = WideCharToMultiByte(936, WC_NO_BEST_FIT_CHARS,
|
||||
committed.data(), static_cast<int>(committed.size()), nullptr, 0,
|
||||
nullptr, &usedDefault);
|
||||
Assert::IsTrue(required > 0);
|
||||
Assert::IsTrue(usedDefault != FALSE);
|
||||
std::string replacement(static_cast<std::size_t>(required), '\0');
|
||||
usedDefault = FALSE;
|
||||
Assert::AreEqual(required, WideCharToMultiByte(936, WC_NO_BEST_FIT_CHARS,
|
||||
committed.data(), static_cast<int>(committed.size()), replacement.data(),
|
||||
required, nullptr, &usedDefault));
|
||||
|
||||
context.seedEchoForTesting(committed, 11);
|
||||
for (const unsigned char byte : replacement)
|
||||
context.collect(nullptr, WM_CHAR, byte, 0);
|
||||
context.collect(nullptr, WM_CHAR, 'A', 0);
|
||||
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
|
||||
Assert::IsNotNull(text);
|
||||
Assert::AreEqual(std::wstring(L"A"), text->text);
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(QueuePreservesStrictOrderAcrossProducerAndConsumerThreads)
|
||||
{
|
||||
constexpr std::uint64_t eventCount = 20000;
|
||||
StellarX::Internal::NativeInputQueue queue;
|
||||
std::atomic<bool> producerDone{ false };
|
||||
std::atomic<bool> failed{ false };
|
||||
|
||||
std::thread producer([&]() {
|
||||
for (std::uint64_t sequence = 1; sequence <= eventCount; ++sequence)
|
||||
{
|
||||
StellarX::NativeInputEvent event;
|
||||
event.sequence = sequence;
|
||||
event.payload = StellarX::KeyEvent{};
|
||||
if (queue.push(std::move(event)) !=
|
||||
StellarX::Internal::NativeInputQueuePushResult::Queued)
|
||||
{
|
||||
failed.store(true, std::memory_order_release);
|
||||
break;
|
||||
}
|
||||
}
|
||||
producerDone.store(true, std::memory_order_release);
|
||||
});
|
||||
|
||||
std::uint64_t expected = 1;
|
||||
while (!producerDone.load(std::memory_order_acquire) || expected <= eventCount)
|
||||
{
|
||||
StellarX::NativeInputEvent event;
|
||||
const auto result = queue.tryPop(event);
|
||||
if (result == StellarX::Internal::NativeInputQueuePopResult::Popped)
|
||||
{
|
||||
if (event.sequence != expected)
|
||||
failed.store(true, std::memory_order_release);
|
||||
++expected;
|
||||
}
|
||||
else if (result == StellarX::Internal::NativeInputQueuePopResult::UnexpectedFailure)
|
||||
{
|
||||
failed.store(true, std::memory_order_release);
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (producerDone.load(std::memory_order_acquire) &&
|
||||
(expected > eventCount || failed.load(std::memory_order_acquire)))
|
||||
break;
|
||||
std::this_thread::yield();
|
||||
}
|
||||
}
|
||||
producer.join();
|
||||
|
||||
Assert::IsFalse(failed.load(std::memory_order_acquire));
|
||||
Assert::AreEqual(eventCount + 1, expected);
|
||||
const auto stats = queue.stats();
|
||||
Assert::AreEqual(eventCount, stats.pushed);
|
||||
Assert::AreEqual(eventCount, stats.popped);
|
||||
Assert::AreEqual<std::size_t>(0, stats.currentDepth);
|
||||
Assert::IsTrue(stats.maximumDepth > 0);
|
||||
}
|
||||
|
||||
TEST_METHOD(AllocationFailureBecomesObservableFailStop)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.forceNextQueuePushResultForTesting(
|
||||
StellarX::Internal::NativeInputQueuePushResult::AllocationFailure);
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
|
||||
Assert::IsTrue(context.hasFault());
|
||||
const auto fault = context.fault();
|
||||
Assert::IsTrue(fault.code == StellarX::Internal::NativeInputFaultCode::OutOfMemory);
|
||||
Assert::AreEqual<UINT>(WM_KEYDOWN, fault.message);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(ObservationDrainIsStrictAndLeavesNoBacklog)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
context.collect(nullptr, WM_KEYUP, 'A', static_cast<LPARAM>(1u << 31));
|
||||
context.collect(nullptr, WM_CHAR, 'a', 1);
|
||||
Assert::AreEqual<std::size_t>(3, context.drainObserved(16));
|
||||
|
||||
const auto observations = context.observationStats();
|
||||
Assert::AreEqual<std::uint64_t>(3, observations.observed);
|
||||
Assert::AreEqual<std::uint64_t>(2, observations.keyEvents);
|
||||
Assert::AreEqual<std::uint64_t>(1, observations.textEvents);
|
||||
Assert::IsTrue(observations.sequenceStrict);
|
||||
Assert::AreEqual<std::size_t>(0, context.queueStats().currentDepth);
|
||||
Assert::IsFalse(context.hasFault());
|
||||
}
|
||||
};
|
||||
|
||||
// 验证文件 sink 的原子切换、失败回退和轮换过程不会丢失日志行。
|
||||
TEST_CLASS(LoggingTests)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user