Очки: жесты и режимы общения с ассистентом (по 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:
+255
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user