Skip to content

Commit 65846f5

Browse files
authored
test(native): gesture replay harness + fixture loader (#78 deliverable 2) (#96)
Add a native gesture-replay harness that can consume TraceRecorder JSON fixtures and run inside the existing host CMake / ctest workflow. New files: tests/replay/trace_fixture.h Header-only C++17 fixture loader (parseFixture / loadFixture). Parses the TraceRecorder schema (version 1) into plain structs with xCoordinates() / yCoordinates() / times() / pointerIds() accessors ready to forward to Suggest::getSuggestions once blockers are lifted. tests/replay/gesture_replay_test.cpp TraceFixtureParserTest (9 enabled tests) — cover JSON parsing, accessor arrays, monotonic timestamps, coordinate bounds, escape handling, and empty-pointer edge cases. DISABLED_GestureReplayTest.ReplayHelloQwerty — compile-checked scaffold documenting the exact API seam and two concrete blockers. tests/replay/fixtures/hello_qwerty.json Seed fixture: 14-sample "hello" trace on a 1080×310 QWERTY keyboard (en-US). Consumed by TraceFixtureParserTest.LoadsHelloQwertyFromFile. CMakeLists.txt: Add target_compile_definitions(FIXTURE_DIR=...) so the file-path test resolves the fixture directory at compile time without network access. Blockers documented in gesture_replay_test.cpp (DISABLED_ comment block): 1. ProximityInfo requires a live JNIEnv* — calls env->GetArrayLength() unconditionally in its constructor; no non-JNI overload exists. Fix: add a raw-pointer constructor or a fake-JNIEnv shim. 2. Dictionary requires a compiled binary .dict asset not in the tree. Fix: bundle a small en_US dict via CMake FetchContent, or generate one programmatically using the existing v4 writer classes. ctest result: 76/76 pass, 1 disabled (GestureReplayTest.ReplayHelloQwerty)
1 parent 1d82631 commit 65846f5

4 files changed

Lines changed: 582 additions & 0 deletions

File tree

app/src/main/jni/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ target_include_directories(latinime_host_unittests PRIVATE ${SRC_DIR} ${TEST_DIR
5555
target_compile_options(latinime_host_unittests PRIVATE
5656
-include ${CMAKE_CURRENT_SOURCE_DIR}/host_test_compat.h
5757
-Wno-unused-parameter -Wno-unused-function)
58+
# Expose the on-disk fixture directory to the replay tests so they can load
59+
# JSON fixtures by path (TraceFixtureParserTest.LoadsHelloQwertyFromFile).
60+
target_compile_definitions(latinime_host_unittests PRIVATE
61+
FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/replay/fixtures")
5862
target_link_libraries(latinime_host_unittests PRIVATE gtest gtest_main)
5963

6064
enable_testing()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"version": 1,
3+
"createdAt": 1720000000000,
4+
"keyboard": {
5+
"width": 1080,
6+
"height": 310,
7+
"mainLayout": "qwerty",
8+
"locale": "en-US"
9+
},
10+
"committedWord": "hello",
11+
"pointers": [
12+
{"id": 0, "x": 648, "y": 185, "t": 0},
13+
{"id": 0, "x": 590, "y": 168, "t": 48},
14+
{"id": 0, "x": 450, "y": 135, "t": 98},
15+
{"id": 0, "x": 340, "y": 110, "t": 148},
16+
{"id": 0, "x": 270, "y": 100, "t": 200},
17+
{"id": 0, "x": 370, "y": 120, "t": 258},
18+
{"id": 0, "x": 540, "y": 158, "t": 308},
19+
{"id": 0, "x": 660, "y": 185, "t": 358},
20+
{"id": 0, "x": 756, "y": 185, "t": 408},
21+
{"id": 0, "x": 810, "y": 185, "t": 450},
22+
{"id": 0, "x": 864, "y": 185, "t": 500},
23+
{"id": 0, "x": 864, "y": 184, "t": 552},
24+
{"id": 0, "x": 891, "y": 143, "t": 600},
25+
{"id": 0, "x": 918, "y": 100, "t": 648}
26+
]
27+
}
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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

Comments
 (0)