Очки: жесты и режимы общения с ассистентом (по docs/gestures-and-modes.md)

- AssistantMode: NONE/LISTENING/THINKING/ANSWER + чистая gestureDecision (матрица жестов)
- Клик в «ответ виден» = новая фраза; DoubleClick = выход (кино на паузе); свайпы = скролл
- Плавный скролл ответа: SlideContinuous delta → answerScrollDelta → scrollOffset (зажим 0..max)
- Индикатор «думаю...» (thinking, сброс на ShowText)
- AssistantOverlay: затемнение 72%, по глазам, скролл-аккумулятор
- Тесты: 22 (AssistantGesturesTest) — glasses 91 зелёных
This commit is contained in:
2026-08-23 01:55:24 +03:00
parent f36d04adc1
commit f75e672819
6 changed files with 664 additions and 90 deletions
@@ -0,0 +1,255 @@
package pw.binom.viewmate.glasses.gestures
import android.view.MotionEvent
import com.rayneo.arsdk.android.touch.FlingArgs
import com.rayneo.arsdk.android.touch.TempleAction
import com.rayneo.arsdk.android.touch.TempleAction.Click
import com.rayneo.arsdk.android.touch.TempleAction.DoubleClick
import com.rayneo.arsdk.android.touch.TempleAction.SlideBackward
import com.rayneo.arsdk.android.touch.TempleAction.SlideContinuous
import com.rayneo.arsdk.android.touch.TempleAction.SlideForward
import com.rayneo.arsdk.android.touch.TempleAction.TripleClick
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import pw.binom.viewmate.core.MediaCommand
/**
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»).
*/
class AssistantGesturesTest {
private val movie = AssistantState()
private val listening = AssistantState(listening = true)
private val thinking = AssistantState(thinking = true)
private val answer = AssistantState(assistantText = "ответ")
// ————— режим по состоянию —————
@Test
fun movieStateIsNone() {
assertEquals(AssistantMode.NONE, assistantMode(movie))
}
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
@Test
fun listeningWinsOverEverything() {
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
assertEquals(AssistantMode.LISTENING, assistantMode(s))
}
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
@Test
fun answerWinsOverThinking() {
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
}
@Test
fun thinkingOnlyWhenNoAnswer() {
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
}
// ————— кино ( NONE) —————
@Test
fun movieMatrix() {
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), movie))
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
assertEquals(
GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 30_000),
gestureDecision(slideForward(), movie),
)
assertEquals(
GestureDecision.Seek(MediaCommand.SEEK_BACKWARD, 30_000),
gestureDecision(slideBackwardAction(), movie),
)
// Свайп «плавного» типа без ответа не управляет скроллом → игнор.
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), movie))
}
@Test
fun movieSeekStepIsParameter() {
val d = gestureDecision(slideForward(), movie, seekStepMs = 10_000)
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
}
// ————— слушаю —————
@Test
fun listeningMatrix() {
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
}
// ————— «думаю» (фраза отправлена, ответа ещё нет) —————
@Test
fun thinkingMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), thinking))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), thinking))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
}
// ————— «ответ виден» —————
@Test
fun answerMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
}
@Test
fun answerPageDeltaIsParameter() {
assertEquals(
GestureDecision.ScrollAnswer(80f),
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
)
}
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
@Test
fun sessionTransitions() {
var s = movie
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
s = s.copy(listening = true)
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет
assertEquals(AssistantMode.THINKING, assistantMode(s))
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
s = s.copy(thinking = false, assistantText = "ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
// Выход: оверлей невидим, кино снова управляется кликом.
val exited = s.copy(assistantText = "")
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
@Test
fun cancelStaysInConversation() {
val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.CancelDictation, decision)
// После отмены ответ предыдущей сессии виден — «ответ виден», выход — DoubleClick.
val after = listening.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(after))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
}
// ————— оверлей —————
@Test
fun overlayInvisibleInMovie() {
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
assertFalse(overlayVisible(movie))
}
@Test
fun overlayVisibleInListeningThinkingAndAnswer() {
assertTrue(overlayVisible(listening))
assertTrue(overlayVisible(thinking))
assertTrue(overlayVisible(answer))
}
@Test
fun thinkingHeaderIsDotDotDot() {
assertEquals("думаю...", overlayHeader(thinking))
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
assertEquals(
"думаю...",
overlayHeader(AssistantState(listening = true, thinking = true)),
)
}
@Test
fun listeningHeaderFallsBackToListening() {
assertEquals("слушаю...", overlayHeader(listening))
assertEquals("привет", overlayHeader(listening.copy(sttText = "привет")))
}
@Test
fun answerHeaderIsLastPhraseOrNull() {
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
assertNull(overlayHeader(answer))
assertNull(overlayHeader(movie))
}
// ————— скролл ответа —————
@Test
fun scrollOffsetAppliesDelta() {
assertEquals(342, scrollOffset(132, 210f, 10_000))
}
@Test
fun scrollOffsetClampsAboveMax() {
assertEquals(1000, scrollOffset(800, 1000f, 1000))
}
@Test
fun scrollOffsetClampsBelowZero() {
assertEquals(0, scrollOffset(100, -500f, 1000))
}
/**
* Float-дельта округляется вниз (к целой части), а не к ближайшему.
* (10.7f в IEEE754 ~ 10.700000763, поэтому 100 - 10.7f = 89.2999… → 89.)
*/
@Test
fun scrollOffsetTruncatesFractionalDelta() {
assertEquals(112, scrollOffset(100, 12.7f, 10_000))
assertEquals(89, scrollOffset(100, -10.7f, 10_000))
}
@Test
fun scrollOffsetWithZeroMax() {
assertEquals(0, scrollOffset(5, 100f, 0))
}
/**
* SDK-жесты в юнит-тестах: MotionEvent создаётся через sun.misc.Unsafe
* (allocateInstance, без конструктора — в JVM-тестах Android-классы «not mocked»).
* Как в GestureCommandsTest.
*/
private fun fakeMotionEvent(): MotionEvent {
val unsafeClass = Class.forName("sun.misc.Unsafe")
val theUnsafe = unsafeClass.getDeclaredField("theUnsafe")
theUnsafe.isAccessible = true
val unsafe = theUnsafe.get(null)
val allocate = unsafeClass.getMethod("allocateInstance", Class::class.java)
return allocate.invoke(unsafe, MotionEvent::class.java) as MotionEvent
}
private fun unsafeFlingArgs(): FlingArgs {
val me1 = fakeMotionEvent()
val me2 = fakeMotionEvent()
return FlingArgs(me1, me2, 1f, 1f)
}
private fun slideForward() = SlideForward(unsafeFlingArgs())
private fun slideBackwardAction() = SlideBackward(unsafeFlingArgs())
/** SlideContinuous: дельта шлейфа; second — флаг long-жеста (на тест не влияет). */
private fun slideContinuous(delta: Float = 9f) = SlideContinuous(delta, false)
}