|
| 1 | +// SPDX-License-Identifier: Apache-2.0 |
| 2 | +// |
| 3 | +// Native gesture-replay harness — issue #78, deliverable 2. |
| 4 | +// |
| 5 | +// ============================================================================= |
| 6 | +// ENABLED tests (TraceFixtureParserTest.*) |
| 7 | +// Validate the JSON fixture loader end-to-end: parse an embedded literal and, |
| 8 | +// when FIXTURE_DIR is defined, a file from the on-disk fixture directory. |
| 9 | +// These always pass in ctest without any runtime assets. |
| 10 | +// |
| 11 | +// DISABLED tests (DISABLED_GestureReplayTest.*) |
| 12 | +// Compile-checked stubs that would feed a loaded trace through the latinime |
| 13 | +// gesture recognizer. Disabled because two blockers must be resolved first: |
| 14 | +// |
| 15 | +// BLOCKER 1 — JNI runtime required by ProximityInfo: |
| 16 | +// ProximityInfo::ProximityInfo(JNIEnv*, ...) calls env->GetArrayLength() and |
| 17 | +// env->GetIntArrayRegion() unconditionally on the very first line of the |
| 18 | +// constructor body (proximity_info.cpp:76). There is no non-JNI constructor |
| 19 | +// overload. A live JVM (JavaVM + JNI_CreateJavaVM, or an Android runtime) |
| 20 | +// must be available to create a ProximityInfo with real keyboard geometry. |
| 21 | +// |
| 22 | +// BLOCKER 2 — binary dictionary asset required: |
| 23 | +// Dictionary wraps a DictionaryStructureWithBufferPolicy that is loaded from |
| 24 | +// a compiled binary .dict file (the Android asset pipeline provides these at |
| 25 | +// runtime; they are not part of the source tree and are not downloaded by the |
| 26 | +// CMake build). Without a valid dict the gesture scorer has nothing to search. |
| 27 | +// |
| 28 | +// Next concrete steps to get real replay assertions: |
| 29 | +// a) Either create a minimal fake JNIEnv shim (filling JNINativeInterface_ |
| 30 | +// function pointers) so ProximityInfo can be constructed on the host, OR |
| 31 | +// add a non-JNI constructor that accepts raw int*/float* arrays directly. |
| 32 | +// b) Download / bundle a small compiled English .dict (e.g. the AOSP |
| 33 | +// "en_US" dict, ~1 MB) as a test asset via CMake FetchContent, OR |
| 34 | +// generate a tiny programmatic dict using the existing v4 writer classes. |
| 35 | +// c) Wire ProximityInfo + Dictionary + DicTraverseSession together and |
| 36 | +// uncomment the assertion in DISABLED_GestureReplayTest.ReplayHelloQwerty. |
| 37 | +// ============================================================================= |
| 38 | + |
| 39 | +#include <gtest/gtest.h> |
| 40 | + |
| 41 | +#include "replay/trace_fixture.h" |
| 42 | + |
| 43 | +namespace latinime { |
| 44 | +namespace replay { |
| 45 | +namespace { |
| 46 | + |
| 47 | +// ---- Embedded fixture (TraceRecorder schema v1, word "hello") --------------- |
| 48 | +// |
| 49 | +// Recorded on a 1080×310 px QWERTY keyboard (en-US). |
| 50 | +// Key-centre estimates (px): |
| 51 | +// h ≈ (648, 185) e ≈ (270, 100) l ≈ (864, 185) o ≈ (918, 100) |
| 52 | +// |
| 53 | +static constexpr const char kHelloQwertyJson[] = |
| 54 | + R"json({"version":1,"createdAt":1720000000000,)json" |
| 55 | + R"json("keyboard":{"width":1080,"height":310,)json" |
| 56 | + R"json("mainLayout":"qwerty","locale":"en-US"},)json" |
| 57 | + R"json("committedWord":"hello",)json" |
| 58 | + R"json("pointers":[)json" |
| 59 | + R"json({"id":0,"x":648,"y":185,"t":0},)json" |
| 60 | + R"json({"id":0,"x":590,"y":168,"t":48},)json" |
| 61 | + R"json({"id":0,"x":450,"y":135,"t":98},)json" |
| 62 | + R"json({"id":0,"x":340,"y":110,"t":148},)json" |
| 63 | + R"json({"id":0,"x":270,"y":100,"t":200},)json" |
| 64 | + R"json({"id":0,"x":370,"y":120,"t":258},)json" |
| 65 | + R"json({"id":0,"x":540,"y":158,"t":308},)json" |
| 66 | + R"json({"id":0,"x":660,"y":185,"t":358},)json" |
| 67 | + R"json({"id":0,"x":756,"y":185,"t":408},)json" |
| 68 | + R"json({"id":0,"x":810,"y":185,"t":450},)json" |
| 69 | + R"json({"id":0,"x":864,"y":185,"t":500},)json" |
| 70 | + R"json({"id":0,"x":864,"y":184,"t":552},)json" |
| 71 | + R"json({"id":0,"x":891,"y":143,"t":600},)json" |
| 72 | + R"json({"id":0,"x":918,"y":100,"t":648})json" |
| 73 | + R"json(]})json"; |
| 74 | + |
| 75 | +// ============================================================================= |
| 76 | +// TraceFixtureParserTest — enabled, runs in ctest |
| 77 | +// ============================================================================= |
| 78 | + |
| 79 | +TEST(TraceFixtureParserTest, ParsesVersionAndMetadata) { |
| 80 | + const TraceFixture fix = parseFixture(kHelloQwertyJson); |
| 81 | + EXPECT_EQ(1, fix.version); |
| 82 | + EXPECT_EQ(1720000000000LL, fix.createdAt); |
| 83 | + EXPECT_EQ("hello", fix.committedWord); |
| 84 | +} |
| 85 | + |
| 86 | +TEST(TraceFixtureParserTest, ParsesKeyboardGeometry) { |
| 87 | + const TraceFixture fix = parseFixture(kHelloQwertyJson); |
| 88 | + EXPECT_EQ(1080, fix.keyboard.width); |
| 89 | + EXPECT_EQ(310, fix.keyboard.height); |
| 90 | + EXPECT_EQ("qwerty", fix.keyboard.mainLayout); |
| 91 | + EXPECT_EQ("en-US", fix.keyboard.locale); |
| 92 | +} |
| 93 | + |
| 94 | +TEST(TraceFixtureParserTest, ParsesPointerCount) { |
| 95 | + const TraceFixture fix = parseFixture(kHelloQwertyJson); |
| 96 | + EXPECT_EQ(14, fix.inputSize()); |
| 97 | + ASSERT_EQ(14u, fix.pointers.size()); |
| 98 | +} |
| 99 | + |
| 100 | +TEST(TraceFixtureParserTest, ParsesFirstAndLastPointerSample) { |
| 101 | + const TraceFixture fix = parseFixture(kHelloQwertyJson); |
| 102 | + |
| 103 | + // First sample — starts over 'h' |
| 104 | + EXPECT_EQ(0, fix.pointers.front().id); |
| 105 | + EXPECT_EQ(648, fix.pointers.front().x); |
| 106 | + EXPECT_EQ(185, fix.pointers.front().y); |
| 107 | + EXPECT_EQ(0, fix.pointers.front().t); |
| 108 | + |
| 109 | + // Last sample — ends over 'o' |
| 110 | + EXPECT_EQ(0, fix.pointers.back().id); |
| 111 | + EXPECT_EQ(918, fix.pointers.back().x); |
| 112 | + EXPECT_EQ(100, fix.pointers.back().y); |
| 113 | + EXPECT_EQ(648, fix.pointers.back().t); |
| 114 | +} |
| 115 | + |
| 116 | +TEST(TraceFixtureParserTest, AccessorArraysMatchPointers) { |
| 117 | + const TraceFixture fix = parseFixture(kHelloQwertyJson); |
| 118 | + const auto xs = fix.xCoordinates(); |
| 119 | + const auto ys = fix.yCoordinates(); |
| 120 | + const auto ts = fix.times(); |
| 121 | + const auto ids = fix.pointerIds(); |
| 122 | + |
| 123 | + ASSERT_EQ(fix.pointers.size(), xs.size()); |
| 124 | + for (std::size_t i = 0; i < fix.pointers.size(); ++i) { |
| 125 | + EXPECT_EQ(fix.pointers[i].x, xs[i]) << "xs mismatch at " << i; |
| 126 | + EXPECT_EQ(fix.pointers[i].y, ys[i]) << "ys mismatch at " << i; |
| 127 | + EXPECT_EQ(fix.pointers[i].t, ts[i]) << "ts mismatch at " << i; |
| 128 | + EXPECT_EQ(fix.pointers[i].id, ids[i]) << "ids mismatch at " << i; |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +TEST(TraceFixtureParserTest, TimestampsAreMonotonicallyNonDecreasing) { |
| 133 | + const TraceFixture fix = parseFixture(kHelloQwertyJson); |
| 134 | + for (std::size_t i = 1; i < fix.pointers.size(); ++i) { |
| 135 | + EXPECT_GE(fix.pointers[i].t, fix.pointers[i - 1].t) |
| 136 | + << "timestamp regression at index " << i; |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +TEST(TraceFixtureParserTest, AllPointersWithinKeyboardBounds) { |
| 141 | + const TraceFixture fix = parseFixture(kHelloQwertyJson); |
| 142 | + for (std::size_t i = 0; i < fix.pointers.size(); ++i) { |
| 143 | + EXPECT_GE(fix.pointers[i].x, 0) << "x < 0 at " << i; |
| 144 | + EXPECT_LE(fix.pointers[i].x, fix.keyboard.width) << "x > width at " << i; |
| 145 | + EXPECT_GE(fix.pointers[i].y, 0) << "y < 0 at " << i; |
| 146 | + EXPECT_LE(fix.pointers[i].y, fix.keyboard.height) << "y > height at " << i; |
| 147 | + } |
| 148 | +} |
| 149 | + |
| 150 | +TEST(TraceFixtureParserTest, ParsesJsonEscapedString) { |
| 151 | + // Verify the parser handles escaped double-quotes and backslashes in strings. |
| 152 | + const std::string json = |
| 153 | + R"({"version":1,"createdAt":0,)" |
| 154 | + R"("keyboard":{"width":100,"height":100,"mainLayout":"a\"b","locale":"c\\d"},)" |
| 155 | + R"("committedWord":"w\"x","pointers":[]})"; |
| 156 | + const TraceFixture fix = parseFixture(json); |
| 157 | + EXPECT_EQ("a\"b", fix.keyboard.mainLayout); |
| 158 | + EXPECT_EQ("c\\d", fix.keyboard.locale); |
| 159 | + EXPECT_EQ("w\"x", fix.committedWord); |
| 160 | +} |
| 161 | + |
| 162 | +TEST(TraceFixtureParserTest, ParsesEmptyPointerArray) { |
| 163 | + const std::string json = |
| 164 | + R"({"version":1,"createdAt":0,)" |
| 165 | + R"("keyboard":{"width":0,"height":0,"mainLayout":"","locale":""},)" |
| 166 | + R"("committedWord":"","pointers":[]})"; |
| 167 | + const TraceFixture fix = parseFixture(json); |
| 168 | + EXPECT_EQ(0, fix.inputSize()); |
| 169 | + EXPECT_TRUE(fix.pointers.empty()); |
| 170 | +} |
| 171 | + |
| 172 | +TEST(TraceFixtureParserTest, ToleratesUnknownTopLevelKeys) { |
| 173 | + // Ensures forward-compatibility: extra keys are silently skipped. |
| 174 | + const std::string json = |
| 175 | + R"({"version":1,"createdAt":0,"newField":{"nested":42},)" |
| 176 | + R"("keyboard":{"width":0,"height":0,"mainLayout":"","locale":""},)" |
| 177 | + R"("committedWord":"hi","pointers":[]})"; |
| 178 | + EXPECT_NO_THROW({ |
| 179 | + const TraceFixture fix = parseFixture(json); |
| 180 | + EXPECT_EQ("hi", fix.committedWord); |
| 181 | + }); |
| 182 | +} |
| 183 | + |
| 184 | +// File-based test: only compiled when FIXTURE_DIR is passed by CMake. |
| 185 | +#if defined(FIXTURE_DIR) |
| 186 | +TEST(TraceFixtureParserTest, LoadsHelloQwertyFromFile) { |
| 187 | + const std::string path = std::string(FIXTURE_DIR) + "/hello_qwerty.json"; |
| 188 | + TraceFixture fix; |
| 189 | + ASSERT_NO_THROW({ fix = loadFixture(path); }) << "path: " << path; |
| 190 | + EXPECT_EQ(1, fix.version); |
| 191 | + EXPECT_EQ("hello", fix.committedWord); |
| 192 | + EXPECT_EQ(1080, fix.keyboard.width); |
| 193 | + EXPECT_GT(fix.inputSize(), 0); |
| 194 | +} |
| 195 | +#endif |
| 196 | + |
| 197 | +// ============================================================================= |
| 198 | +// DISABLED_GestureReplayTest — compile-checked scaffolding; skipped in ctest. |
| 199 | +// |
| 200 | +// Blocked by: |
| 201 | +// 1. ProximityInfo requires JNIEnv* + jintArray/jfloatArray (JVM must be live). |
| 202 | +// 2. Dictionary requires a binary .dict asset loaded from disk. |
| 203 | +// |
| 204 | +// To re-enable, prefix the test name with nothing (remove "DISABLED_") after |
| 205 | +// both blockers are resolved. See file header for the resolution path. |
| 206 | +// ============================================================================= |
| 207 | + |
| 208 | +// Prevent "unused include" warnings while the DISABLED_ tests are inactive. |
| 209 | +// The includes below are intentional — they document the API seam and will be |
| 210 | +// used once the blockers are lifted. |
| 211 | +// |
| 212 | +// #include "suggest/core/layout/proximity_info.h" // needs JNIEnv* |
| 213 | +// #include "suggest/core/dictionary/dictionary.h" // needs .dict asset |
| 214 | +// #include "suggest/core/session/dic_traverse_session.h" // needs JNIEnv* |
| 215 | +// #include "suggest/core/result/suggestion_results.h" |
| 216 | +// #include "suggest/core/suggest_options.h" |
| 217 | +// #include "dictionary/property/ngram_context.h" |
| 218 | + |
| 219 | +TEST(DISABLED_GestureReplayTest, ReplayHelloQwerty) { |
| 220 | + // --- Step 1: Load fixture --- |
| 221 | + const TraceFixture fix = parseFixture(kHelloQwertyJson); |
| 222 | + ASSERT_EQ("hello", fix.committedWord); |
| 223 | + |
| 224 | + // --- Step 2: Build ProximityInfo from fixture keyboard geometry --- |
| 225 | + // |
| 226 | + // BLOCKED: ProximityInfo constructor signature (proximity_info.h:31): |
| 227 | + // |
| 228 | + // ProximityInfo(JNIEnv *env, |
| 229 | + // int keyboardWidth, int keyboardHeight, |
| 230 | + // int gridWidth, int gridHeight, |
| 231 | + // int mostCommonKeyWidth, int mostCommonKeyHeight, |
| 232 | + // jintArray proximityChars, // JNI array — needs live JVM |
| 233 | + // int keyCount, |
| 234 | + // jintArray keyXCoordinates, // JNI array |
| 235 | + // jintArray keyYCoordinates, // JNI array |
| 236 | + // jintArray keyWidths, // JNI array |
| 237 | + // jintArray keyHeights, // JNI array |
| 238 | + // jintArray keyCharCodes, // JNI array |
| 239 | + // jfloatArray sweetSpotCenterXs, // JNI float array |
| 240 | + // jfloatArray sweetSpotCenterYs, // JNI float array |
| 241 | + // jfloatArray sweetSpotRadii); // JNI float array |
| 242 | + // |
| 243 | + // The body immediately calls env->GetArrayLength(proximityChars) and |
| 244 | + // env->GetIntArrayRegion(...), so even a null JNIEnv* would crash. |
| 245 | + // Resolution: add a non-JNI constructor that accepts raw int*/float* arrays. |
| 246 | + |
| 247 | + // ProximityInfo pInfo(env, fix.keyboard.width, fix.keyboard.height, ...); |
| 248 | + |
| 249 | + // --- Step 3: Load a binary dictionary --- |
| 250 | + // |
| 251 | + // BLOCKED: requires a compiled en_US .dict binary. The latinime app loads |
| 252 | + // these from its APK assets folder at runtime; they are not in the source |
| 253 | + // tree. For host tests, a small test dictionary must be bundled (e.g. via |
| 254 | + // CMake FetchContent) or generated programmatically using the v4 writer. |
| 255 | + |
| 256 | + // const char *dictPath = "/path/to/en_US.dict"; |
| 257 | + // auto policy = DictionaryStructureWithBufferPolicyFactory::newPolicyForExistingDictFile( |
| 258 | + // dictPath, 0, fileSize, false); |
| 259 | + // Dictionary dict(env, std::move(policy)); |
| 260 | + |
| 261 | + // --- Step 4: Create DicTraverseSession --- |
| 262 | + // |
| 263 | + // DicTraverseSession also takes JNIEnv* + jstring locale (same blocker). |
| 264 | + |
| 265 | + // DicTraverseSession session(env, localeJstr, /*usesLargeCache=*/false); |
| 266 | + // session.init(&dict, nullptr, &suggestOptions); |
| 267 | + |
| 268 | + // --- Step 5: Call getSuggestions --- |
| 269 | + // |
| 270 | + // Suggest::getSuggestions(ProximityInfo*, void* traverseSession, |
| 271 | + // int* inputXs, int* inputYs, int* times, int* pointerIds, |
| 272 | + // int* inputCodePoints, int inputSize, |
| 273 | + // float weightOfLangModelVsSpatialModel, |
| 274 | + // SuggestionResults* outSuggestionResults) |
| 275 | + // |
| 276 | + // Once unblocked, wire like: |
| 277 | + // |
| 278 | + // auto xs = fix.xCoordinates(); |
| 279 | + // auto ys = fix.yCoordinates(); |
| 280 | + // auto ts = fix.times(); |
| 281 | + // auto ids = fix.pointerIds(); |
| 282 | + // SuggestionResults results(MAX_RESULTS); |
| 283 | + // NgramContext ngramCtx; |
| 284 | + // SuggestOptions opts; |
| 285 | + // dict.getSuggestions(&pInfo, &session, |
| 286 | + // xs.data(), ys.data(), ts.data(), ids.data(), |
| 287 | + // /*inputCodePoints=*/nullptr, fix.inputSize(), |
| 288 | + // &ngramCtx, &opts, |
| 289 | + // NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL, |
| 290 | + // &results); |
| 291 | + // |
| 292 | + // Then assert the top suggestion matches committedWord: |
| 293 | + // |
| 294 | + // ASSERT_GT(results.getSuggestionsCount(), 0); |
| 295 | + // int topWordCodePoints[MAX_WORD_LENGTH]; |
| 296 | + // results.getSortedScores(); // sort descending |
| 297 | + // // decode first result and compare to fix.committedWord |
| 298 | + // char topWord[MAX_WORD_LENGTH * 4 + 1]; |
| 299 | + // intArrayToCharArray(topWordCodePoints, topWordLen, topWord, sizeof(topWord)); |
| 300 | + // EXPECT_EQ(fix.committedWord, std::string(topWord)); |
| 301 | + |
| 302 | + GTEST_SKIP() << "GestureReplayTest disabled: see file header for blocker details."; |
| 303 | +} |
| 304 | + |
| 305 | +} // namespace |
| 306 | +} // namespace replay |
| 307 | +} // namespace latinime |
0 commit comments