diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 834cc1561..202d5e462 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -100,6 +100,10 @@ public final class InputLogic { // signal-driven grace + two-gate Assisted-tier logic. private boolean mSpacingComplete; // typed word is a real dictionary word private float mSpacingPrefixRichScore; // fraction of candidates that are completions [0..1] + // #24 last gate-decision snapshot — null when the Assisted tier is off (default). + // Read by A11y / TraceRecorder / replay harness; never drives commit behaviour yet. + @Nullable + private SpacingGateDecision mLastSpacingGateDecision; private final Suggest mSuggest; private final DictionaryFacilitator mDictionaryFacilitator; private SingleDictionaryFacilitator mEmojiDictionaryFacilitator; @@ -990,7 +994,14 @@ private void enterCombiningMode(final SettingsValues settingsValues, final boole // separators / cursor-front recompositions there's nothing to auto-commit, and arming // the timer would draw a spurious progress bar. if (!mWordComposer.isComposingWord()) return; - final int graceMs = baseGraceMs + Math.max(0, settingsValues.mCombiningTapExtraMs); + final int graceMs; + if (settingsValues.mSpacingSignalDrivenGrace) { + // #24: vary the grace duration by the per-keystroke word-state signals. + graceMs = signalDrivenGraceMs(baseGraceMs, settingsValues.mSpacingCompleteBonusMs, + settingsValues.mSpacingPrefixPenaltyMs, mSpacingComplete, mSpacingPrefixRichScore); + } else { + graceMs = baseGraceMs + Math.max(0, settingsValues.mCombiningTapExtraMs); + } cancelCombiningTimerOnly(); mInCombiningMode = true; // #14 "only auto-finish swiped words": still ENTER combining mode (so a following swipe @@ -1407,6 +1418,15 @@ public void setSuggestedWords(final SuggestedWords suggestedWords) { final SpacingSignals spacingSignals = computeSpacingSignals(suggestedWords); mSpacingComplete = spacingSignals.complete; mSpacingPrefixRichScore = spacingSignals.prefixRichScore; + // #24 gate-decision: evaluate and snapshot whenever the Assisted tier is on. + // Stored for trace/A11y consumption; does NOT drive any commit behaviour yet. + final SettingsValues sv = Settings.getInstance().getCurrent(); + if (sv != null && sv.mSpacingAssistedTier) { + mLastSpacingGateDecision = SpacingGateDecision.evaluate( + true, mSpacingComplete, mSpacingPrefixRichScore, sv.mSpacingLowThreshold); + } else { + mLastSpacingGateDecision = null; + } final boolean newAutoCorrectionIndicator = suggestedWords.mWillAutoCorrect; // Put a blue underline to a word in TextView which will be auto-corrected. @@ -1461,6 +1481,31 @@ static SpacingSignals computeSpacingSignals(final SuggestedWords suggestedWords) return new SpacingSignals(complete, (float) completions / n); } + private static final int SIGNAL_GRACE_MIN_MS = 100; + private static final int SIGNAL_GRACE_MAX_MS = 3000; + + /** + * #24 signal-driven grace duration: a confident complete word commits sooner (subtract + * {@code completeBonus}), while an extendable prefix-rich stem waits longer (add + * {@code prefixPenalty} scaled by the score), clamped to a sane range. Pure for testability. + */ + static int signalDrivenGraceMs(final int baseMs, final int completeBonusMs, + final int prefixPenaltyMs, final boolean complete, final float prefixRichScore) { + final int ms = baseMs - (complete ? completeBonusMs : 0) + + Math.round(prefixPenaltyMs * prefixRichScore); + return Math.max(SIGNAL_GRACE_MIN_MS, Math.min(SIGNAL_GRACE_MAX_MS, ms)); + } + + /** + * Returns the last gate-decision snapshot (for A11y / TraceRecorder / replay-harness + * consumption), or {@code null} when the Assisted tier is disabled (the default). + * Never drives commit behaviour on its own. + */ + @Nullable + public SpacingGateDecision getLastSpacingGateDecision() { + return mLastSpacingGateDecision; + } + /** * Handle a consumed event. *

diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/SpacingGateModel.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/SpacingGateModel.kt new file mode 100644 index 000000000..a08e72c6e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/SpacingGateModel.kt @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.inputlogic + +/** + * Pure two-gate decision model for #24 "Assisted" spacing-policy tier. + * + * Stateless and Android-free: every input is explicit, so the whole thing is unit-testable + * on the JVM without a running InputLogic. Production wiring into [InputLogic.enterCombiningMode] + * is intentionally deferred — this file only provides the model + trace carrier. + * + * Gate table (see docs/SPACING_POLICY.md §5): + * + * | Gate | Condition | Behaviour | + * |------------|-------------------------------------------------|------------------------| + * | A — INSTANT | complete && prefixRichScore ≤ lowThreshold | commit immediately | + * | B — PAUSE | complete && prefixRichScore > lowThreshold | wait for inter-word pause | + * | NONE | !complete (or policy disabled) | fall back to grace timer | + */ +object SpacingGateModel { + + /** + * Which gate (if any) the current word falls into. + * + * - [NONE] — policy disabled, or word not complete; fall back to signal-driven grace timer. + * - [INSTANT] — Gate A: word is finished and prefix-richness is low → commit immediately. + * - [PAUSE] — Gate B: word is finished but prefix-richness is high → wait for the inter-word + * pause threshold before committing. + */ + enum class Gate { NONE, INSTANT, PAUSE } + + /** + * Decide which gate applies. + * + * @param policyEnabled master "Assisted tier" switch (default off — see + * [helium314.keyboard.latin.settings.Settings.PREF_SPACING_ASSISTED_TIER]) + * @param complete the typed word is a real dictionary word (not user-typed-only) + * @param prefixRichScore fraction of suggestion candidates that are completions [0..1] + * @param lowThreshold Gate-A ceiling: score ≤ this triggers [Gate.INSTANT] + * (see [helium314.keyboard.latin.settings.Settings.PREF_SPACING_LOW_THRESHOLD]) + * @return the applicable gate, or [Gate.NONE] when conditions are not met + */ + @JvmStatic + fun decide( + policyEnabled: Boolean, + complete: Boolean, + prefixRichScore: Float, + lowThreshold: Float, + ): Gate { + if (!policyEnabled || !complete) return Gate.NONE + return if (prefixRichScore <= lowThreshold) Gate.INSTANT else Gate.PAUSE + } +} + +/** + * Immutable snapshot of the most recent gate evaluation, held by [InputLogic] as + * `mLastSpacingGateDecision` for A11y / TraceRecorder / replay-harness consumption. + * + * The field is `@Nullable` so callers that have the Assisted tier disabled pay nothing — + * no allocation until the policy is actually on. Use [SpacingGateDecision.update] to + * produce a fresh snapshot cheaply. + */ +data class SpacingGateDecision( + /** The gate that fired (or [SpacingGateModel.Gate.NONE]). */ + val gate: SpacingGateModel.Gate, + /** The `complete` signal at decision time. */ + val complete: Boolean, + /** The `prefixRichScore` signal at decision time [0..1]. */ + val prefixRichScore: Float, + /** The `lowThreshold` used at decision time. */ + val lowThreshold: Float, +) { + companion object { + /** + * Convenience factory: evaluate [SpacingGateModel.decide] and wrap the result. + * Called from [InputLogic.setSuggestedWords] when the Assisted tier is enabled. + */ + @JvmStatic + fun evaluate( + policyEnabled: Boolean, + complete: Boolean, + prefixRichScore: Float, + lowThreshold: Float, + ): SpacingGateDecision = SpacingGateDecision( + gate = SpacingGateModel.decide(policyEnabled, complete, prefixRichScore, lowThreshold), + complete = complete, + prefixRichScore = prefixRichScore, + lowThreshold = lowThreshold, + ) + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index c0e875464..206f7f75b 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -151,6 +151,13 @@ object Defaults { const val PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE = false const val PREF_SPACING_DEFER_GRACE_SPACE = false const val PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE = true // default on: tapped words shouldn't auto-finish + const val PREF_SPACING_SIGNAL_DRIVEN_GRACE = false + const val PREF_SPACING_COMPLETE_BONUS_MS = 200 // complete word commits this much sooner + const val PREF_SPACING_PREFIX_PENALTY_MS = 400 // max extra wait when fully prefix-rich + // #24 two-gate Assisted-tier: master switch (default off) + Gate-A low-threshold. + // 0.1f is conservative: only clearly non-extendable words (very few completions) get Gate A. + const val PREF_SPACING_ASSISTED_TIER = false + const val PREF_SPACING_LOW_THRESHOLD = 0.1f const val PREF_COMBINING_AUTOSPACE_SUGGESTIONS = "alternatives_then_next_word" const val PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD = true const val PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT = true diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index cf89a5379..6255fbda4 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -163,6 +163,16 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang // #14: when on, the combining grace timer only auto-commits words that include a swipe — // pure tap-typed words are never auto-finished by the timer. Experimental, default off. public static final String PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE = "combining_grace_only_after_gesture"; + // #14/#24 signal-driven grace: vary the grace-timer duration by the per-keystroke word-state + // signals (complete / prefix-richness) instead of a fixed value. Experimental, default off. + public static final String PREF_SPACING_SIGNAL_DRIVEN_GRACE = "spacing_signal_driven_grace"; + public static final String PREF_SPACING_COMPLETE_BONUS_MS = "spacing_complete_bonus_ms"; + public static final String PREF_SPACING_PREFIX_PENALTY_MS = "spacing_prefix_penalty_ms"; + // #24 two-gate Assisted-tier: master on/off + Gate-A low-threshold. Both experimental, + // default off / conservative. The gate decision model is in SpacingGateModel.kt; production + // wiring into enterCombiningMode is deferred to the next phase. + public static final String PREF_SPACING_ASSISTED_TIER = "spacing_assisted_tier"; + public static final String PREF_SPACING_LOW_THRESHOLD = "spacing_low_threshold"; // What the suggestion strip shows after the combining grace timer auto-commits a word. // Values: "keep_alternatives" (1) | "next_word" (2, default) | "alternatives_then_next_word" (3). public static final String PREF_COMBINING_AUTOSPACE_SUGGESTIONS = "combining_autospace_suggestions"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index 95efd7756..ed7b32ce2 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -139,6 +139,12 @@ public class SettingsValues { public final boolean mCombiningAutospaceOnlyAfterGesture; public final boolean mSpacingDeferGraceSpace; public final boolean mCombiningGraceOnlyAfterGesture; + public final boolean mSpacingSignalDrivenGrace; + public final int mSpacingCompleteBonusMs; + public final int mSpacingPrefixPenaltyMs; + // #24 two-gate Assisted-tier (experimental, default off). + public final boolean mSpacingAssistedTier; + public final float mSpacingLowThreshold; // Raw string value: "keep_alternatives" | "next_word" | "alternatives_then_next_word" public final String mCombiningAutospaceSuggestions; public final boolean mCombiningBackspaceDeletesGestureWord; @@ -390,6 +396,17 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina mCombiningGraceOnlyAfterGesture = prefs.getBoolean( Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE, Defaults.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE); + mSpacingSignalDrivenGrace = prefs.getBoolean( + Settings.PREF_SPACING_SIGNAL_DRIVEN_GRACE, + Defaults.PREF_SPACING_SIGNAL_DRIVEN_GRACE); + mSpacingCompleteBonusMs = prefs.getInt(Settings.PREF_SPACING_COMPLETE_BONUS_MS, + Defaults.PREF_SPACING_COMPLETE_BONUS_MS); + mSpacingPrefixPenaltyMs = prefs.getInt(Settings.PREF_SPACING_PREFIX_PENALTY_MS, + Defaults.PREF_SPACING_PREFIX_PENALTY_MS); + mSpacingAssistedTier = prefs.getBoolean(Settings.PREF_SPACING_ASSISTED_TIER, + Defaults.PREF_SPACING_ASSISTED_TIER); + mSpacingLowThreshold = prefs.getFloat(Settings.PREF_SPACING_LOW_THRESHOLD, + Defaults.PREF_SPACING_LOW_THRESHOLD); mCombiningAutospaceSuggestions = prefs.getString(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS, Defaults.PREF_COMBINING_AUTOSPACE_SUGGESTIONS); final boolean nonNormalTwoThumbSpacing = mGestureManualSpacing || mCombiningGraceMs > 0; diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt index e73389788..de9f98db6 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt @@ -71,6 +71,9 @@ fun TwoThumbTypingScreen( add(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS) add(Settings.PREF_SPACING_DEFER_GRACE_SPACE) add(Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE) + add(Settings.PREF_SPACING_SIGNAL_DRIVEN_GRACE) + add(Settings.PREF_SPACING_COMPLETE_BONUS_MS) + add(Settings.PREF_SPACING_PREFIX_PENALTY_MS) } if (nonNormalSpacing) { add(Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS) @@ -158,6 +161,44 @@ fun createTwoThumbTypingSettings(context: Context) = listOf( R.string.combining_grace_only_after_gesture_summary) { SwitchPreference(it, Defaults.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE) }, + Setting(context, Settings.PREF_SPACING_SIGNAL_DRIVEN_GRACE, + R.string.spacing_signal_driven_grace, R.string.spacing_signal_driven_grace_summary) { + SwitchPreference(it, Defaults.PREF_SPACING_SIGNAL_DRIVEN_GRACE) + }, + Setting(context, Settings.PREF_SPACING_COMPLETE_BONUS_MS, + R.string.spacing_complete_bonus, R.string.spacing_complete_bonus_summary) { def -> + SliderPreference( + name = def.title, + key = def.key, + default = Defaults.PREF_SPACING_COMPLETE_BONUS_MS, + range = 0f..1000f, + description = { stringResource(R.string.abbreviation_unit_milliseconds, it.toString()) } + ) + }, + Setting(context, Settings.PREF_SPACING_PREFIX_PENALTY_MS, + R.string.spacing_prefix_penalty, R.string.spacing_prefix_penalty_summary) { def -> + SliderPreference( + name = def.title, + key = def.key, + default = Defaults.PREF_SPACING_PREFIX_PENALTY_MS, + range = 0f..1500f, + description = { stringResource(R.string.abbreviation_unit_milliseconds, it.toString()) } + ) + }, + Setting(context, Settings.PREF_SPACING_ASSISTED_TIER, + R.string.spacing_assisted_tier, R.string.spacing_assisted_tier_summary) { + SwitchPreference(it, Defaults.PREF_SPACING_ASSISTED_TIER) + }, + Setting(context, Settings.PREF_SPACING_LOW_THRESHOLD, + R.string.spacing_low_threshold, R.string.spacing_low_threshold_summary) { def -> + SliderPreference( + name = def.title, + key = def.key, + default = Defaults.PREF_SPACING_LOW_THRESHOLD, + range = 0f..1f, + description = { value -> "%.2f".format(value) } + ) + }, Setting(context, Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS, R.string.combining_autospace_suggestions, R.string.combining_autospace_suggestions_summary) { def -> val items = listOf( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82e563c1c..781e638d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,6 +303,18 @@ Only auto-finish swiped words The pause timer auto-commits a word only when it includes a swipe. Words you tap out stay open until you press space or pick a suggestion, so tapped shortcuts and corrections won\'t fire early. On by default \u2014 this controls whether the word commits (the auto-space option above only controls the trailing space). + + Adapt pause to the word (experimental) + Vary the auto-finish pause by what you\'re typing: a finished dictionary word commits sooner, while a stem that many longer words start with waits longer. Tune the two amounts below. + Finished-word speed-up + How much sooner a complete dictionary word auto-finishes. + Extendable-stem patience + Extra wait when many longer words start with what you\'ve typed (so you can keep going). + + Assisted auto-commit (experimental) + Instantly commit clearly finished words (Gate A) and hold extendable stems until you pause (Gate B). Default off \u2014 requires the pause timer above to be active. + Finished-word certainty + Words whose prefix-richness score is at or below this value get instant auto-commit (Gate A). Lower = more selective (fewer instant commits). Default 0.10. Backspace deletes last swipe diff --git a/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingGateModelTest.kt b/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingGateModelTest.kt new file mode 100644 index 000000000..689064058 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingGateModelTest.kt @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.inputlogic + +import helium314.keyboard.latin.inputlogic.SpacingGateModel.Gate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Unit tests for [SpacingGateModel.decide] and [SpacingGateDecision]. + * + * The model is pure/stateless — every case is table-driven with explicit inputs; no Android + * dependencies, no InputLogic instantiation required. + */ +class SpacingGateModelTest { + + private val LOW = 0.1f // default threshold from Defaults.PREF_SPACING_LOW_THRESHOLD + + // ---- policy-disabled fast path ---- + + @Test fun `policy off always returns NONE regardless of signals`() { + assertEquals(Gate.NONE, decide(off = true, complete = true, score = 0.0f)) + assertEquals(Gate.NONE, decide(off = true, complete = true, score = 0.5f)) + assertEquals(Gate.NONE, decide(off = true, complete = false, score = 0.0f)) + assertEquals(Gate.NONE, decide(off = true, complete = false, score = 1.0f)) + } + + // ---- incomplete word (neither gate) ---- + + @Test fun `incomplete word returns NONE even if policy is on`() { + assertEquals(Gate.NONE, decide(complete = false, score = 0.0f)) + assertEquals(Gate.NONE, decide(complete = false, score = LOW)) + assertEquals(Gate.NONE, decide(complete = false, score = 1.0f)) + } + + // ---- Gate A: complete AND score <= threshold ---- + + @Test fun `zero prefix-richness triggers Gate A (instant commit)`() { + assertEquals(Gate.INSTANT, decide(complete = true, score = 0.0f)) + } + + @Test fun `score exactly at threshold triggers Gate A`() { + assertEquals(Gate.INSTANT, decide(complete = true, score = LOW)) + } + + @Test fun `score just below threshold triggers Gate A`() { + val just = LOW - 0.001f + assertEquals(Gate.INSTANT, decide(complete = true, score = just)) + } + + // ---- Gate B: complete AND score > threshold ---- + + @Test fun `score just above threshold triggers Gate B (pause)`() { + val just = LOW + 0.001f + assertEquals(Gate.PAUSE, decide(complete = true, score = just)) + } + + @Test fun `fully prefix-rich complete word triggers Gate B`() { + assertEquals(Gate.PAUSE, decide(complete = true, score = 1.0f)) + } + + @Test fun `mid-range prefix-rich complete word triggers Gate B`() { + assertEquals(Gate.PAUSE, decide(complete = true, score = 0.5f)) + } + + // ---- threshold sensitivity ---- + + @Test fun `custom high threshold raises the bar for Gate B`() { + // threshold = 0.5: score 0.5 still INSTANT; score 0.51 → PAUSE + assertEquals(Gate.INSTANT, decide(complete = true, score = 0.5f, threshold = 0.5f)) + assertEquals(Gate.PAUSE, decide(complete = true, score = 0.51f, threshold = 0.5f)) + } + + @Test fun `threshold zero means only zero score is Gate A`() { + assertEquals(Gate.INSTANT, decide(complete = true, score = 0.0f, threshold = 0.0f)) + assertEquals(Gate.PAUSE, decide(complete = true, score = 0.001f, threshold = 0.0f)) + } + + @Test fun `threshold one means all complete words are Gate A`() { + assertEquals(Gate.INSTANT, decide(complete = true, score = 0.0f, threshold = 1.0f)) + assertEquals(Gate.INSTANT, decide(complete = true, score = 0.5f, threshold = 1.0f)) + assertEquals(Gate.INSTANT, decide(complete = true, score = 1.0f, threshold = 1.0f)) + } + + // ---- boundary: incomplete + zero score still NONE ---- + + @Test fun `incomplete word with zero prefix-richness stays NONE`() { + assertEquals(Gate.NONE, decide(complete = false, score = 0.0f)) + } + + // ---- SpacingGateDecision.evaluate mirrors decide ---- + + @Test fun `SpacingGateDecision evaluate matches decide for all gates`() { + for ((complete, score, threshold, expected) in gateTable()) { + val decision = SpacingGateDecision.evaluate( + policyEnabled = true, complete = complete, + prefixRichScore = score, lowThreshold = threshold + ) + assertEquals("complete=$complete score=$score threshold=$threshold", + expected, decision.gate) + assertEquals(complete, decision.complete) + assertEquals(score, decision.prefixRichScore, 1e-7f) + assertEquals(threshold, decision.lowThreshold, 1e-7f) + } + } + + @Test fun `SpacingGateDecision evaluate with policy off returns NONE`() { + val d = SpacingGateDecision.evaluate( + policyEnabled = false, complete = true, prefixRichScore = 0.0f, lowThreshold = LOW + ) + assertEquals(Gate.NONE, d.gate) + } + + @Test fun `SpacingGateDecision captures all inputs faithfully`() { + val d = SpacingGateDecision.evaluate( + policyEnabled = true, complete = true, prefixRichScore = 0.3f, lowThreshold = 0.2f + ) + assertNotNull(d) + assertEquals(Gate.PAUSE, d.gate) + assertEquals(true, d.complete) + assertEquals(0.3f, d.prefixRichScore, 1e-7f) + assertEquals(0.2f, d.lowThreshold, 1e-7f) + } + + // ---- helpers ---- + + /** Delegate with named parameters for readability; `off` inverts `policyEnabled`. */ + private fun decide( + complete: Boolean, + score: Float, + threshold: Float = LOW, + off: Boolean = false, + ): Gate = SpacingGateModel.decide( + policyEnabled = !off, + complete = complete, + prefixRichScore = score, + lowThreshold = threshold, + ) + + /** Table of (complete, score, threshold, expectedGate) for the evaluate-roundtrip test. */ + private fun gateTable(): List = listOf( + Triple4(false, 0.0f, LOW, Gate.NONE), + Triple4(false, 0.5f, LOW, Gate.NONE), + Triple4(true, 0.0f, LOW, Gate.INSTANT), + Triple4(true, LOW, LOW, Gate.INSTANT), + Triple4(true, LOW + 0.01f, LOW, Gate.PAUSE), + Triple4(true, 1.0f, LOW, Gate.PAUSE), + ) + + private data class Triple4( + val complete: Boolean, + val score: Float, + val threshold: Float, + val gate: Gate, + ) +} diff --git a/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt b/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt index e3296d37d..56844c653 100644 --- a/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt @@ -66,4 +66,27 @@ class SpacingSignalsTest { assertEquals(0f, InputLogic.computeSpacingSignals(words(typed, true, listOf(typed))).prefixRichScore, 0f) } + + // ---- signalDrivenGraceMs ---- + + @Test fun `signal grace is base when neutral`() { + assertEquals(800, InputLogic.signalDrivenGraceMs(800, 200, 400, false, 0f)) + } + + @Test fun `complete word shortens grace`() { + assertEquals(600, InputLogic.signalDrivenGraceMs(800, 200, 400, true, 0f)) + } + + @Test fun `prefix-rich stem lengthens grace`() { + assertEquals(1000, InputLogic.signalDrivenGraceMs(800, 200, 400, false, 0.5f)) // 800 + 400*0.5 + } + + @Test fun `complete and prefix-rich combine`() { + assertEquals(800, InputLogic.signalDrivenGraceMs(800, 200, 400, true, 0.5f)) // 800 - 200 + 200 + } + + @Test fun `grace clamps to the floor and ceiling`() { + assertEquals(100, InputLogic.signalDrivenGraceMs(150, 200, 0, true, 0f)) // -50 -> 100 + assertEquals(3000, InputLogic.signalDrivenGraceMs(2900, 0, 400, false, 1f)) // 3300 -> 3000 + } } diff --git a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt index 30451afb7..400dfa019 100644 --- a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt +++ b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt @@ -72,6 +72,24 @@ class SettingsContainerTest { container[Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE]?.key) } + @Test + fun signalDrivenGraceSettingsAreRegistered() { + assertEquals(Settings.PREF_SPACING_SIGNAL_DRIVEN_GRACE, + container[Settings.PREF_SPACING_SIGNAL_DRIVEN_GRACE]?.key) + assertEquals(Settings.PREF_SPACING_COMPLETE_BONUS_MS, + container[Settings.PREF_SPACING_COMPLETE_BONUS_MS]?.key) + assertEquals(Settings.PREF_SPACING_PREFIX_PENALTY_MS, + container[Settings.PREF_SPACING_PREFIX_PENALTY_MS]?.key) + } + + @Test + fun assistedTierSettingsAreRegistered() { + assertEquals(Settings.PREF_SPACING_ASSISTED_TIER, + container[Settings.PREF_SPACING_ASSISTED_TIER]?.key) + assertEquals(Settings.PREF_SPACING_LOW_THRESHOLD, + container[Settings.PREF_SPACING_LOW_THRESHOLD]?.key) + } + @Test fun twoThumbLowLevelBackspaceSettingIsHiddenFromSearchRegistry() { assertNull(container[Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD])