Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1bc2583
test(arquitetura): as convenções que custaram horas de CI viram regra…
yan-vidal Oct 10, 2026
08c0475
test(arquitetura): as regras ignoram comentários de bloco — o KDoc po…
yan-vidal Oct 10, 2026
cc8a259
feat(debug): StrictMode no build de debug, com as violações guardadas…
yan-vidal Oct 10, 2026
fae40da
perf(teclado): abrir o teclado sem disco na thread principal
yan-vidal Oct 10, 2026
12a03c3
ci: cobertura dos testes de unidade, e os APKs saem antes dos testes
yan-vidal Oct 10, 2026
382ade6
test(propriedade): testes de propriedade com kotest-property — codec …
yan-vidal Oct 10, 2026
1148a4a
fix(teclado): a última tecla da fileira acaba exatamente na borda — a…
yan-vidal Oct 10, 2026
7ca28cf
test(fuzz): fuzzing das entradas de fora — pacote de skin, manifest, …
yan-vidal Oct 10, 2026
d009c56
fix(gravação): o coordenador só aplica efeitos quando a máquina muda …
yan-vidal Oct 10, 2026
c293c49
test(gravação): máquina de estados do coordenador com sequências alea…
yan-vidal Oct 10, 2026
3c330eb
fix(teclado): o diagnóstico do 1º quadro só no Android 10+
yan-vidal Oct 10, 2026
29f19e3
test(mutação): PIT na lógica pura, semanal e manual
yan-vidal Oct 10, 2026
964a1b5
docs(testes): as técnicas além do teste de exemplo
yan-vidal Oct 10, 2026
c60e473
ci(gate): o script encerra o emulador e os ajudantes dele antes de sair
yan-vidal Oct 10, 2026
8765405
test(gravação): o cronômetro da gravação entra na máquina de estados
yan-vidal Oct 10, 2026
23af95b
perf(teclado): seções de trace na abertura do teclado
yan-vidal Oct 11, 2026
b489fb2
perf: perfil de base (Baseline Profile) e benchmarks de abertura
yan-vidal Oct 11, 2026
f8bcfb9
test(agente): a espera pela resposta diz o que havia quando estoura
yan-vidal Oct 11, 2026
b5fa8e0
ci(gate): o encerramento do emulador só acusa QEMU, netsimd e crashpad
yan-vidal Oct 11, 2026
464f0ef
fix(galeria): a espera pelo quadro entregue confere a versão do Android
yan-vidal Oct 11, 2026
5203eb2
ci: lint de versão de API (NewApi) no job de unidade
yan-vidal Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ jobs:
with:
path: app/libs/sherpa-onnx-${{ steps.sherpa.outputs.version }}.aar
key: sherpa-onnx-${{ steps.sherpa.outputs.version }}-${{ steps.sherpa.outputs.sha }}
- name: Unit tests and APKs
run: ./gradlew --no-daemon testDebugUnitTest assembleDebug assembleDebugAndroidTest
# Os APKs primeiro: os jobs do emulador esperam por eles (é o caminho crítico do gate). Os testes de unidade e a
# cobertura vêm depois, já sem atrasar ninguém — juntos no mesmo passo, os testes empurravam os APKs ~30 s.
- name: APKs
run: ./gradlew --no-daemon assembleDebug assembleDebugAndroidTest
# Os jobs do emulador não compilam: instalam estes APKs. Sobem antes do resto para eles saírem
# da espera o quanto antes. APK já é comprimido (compression-level 0); 1 dia de retenção porque só
# servem a esta rodada, e o armazenamento conta na cota do plano.
Expand All @@ -78,12 +80,27 @@ jobs:
# mesmos do debug (só menos ABIs).
- name: 16 KB alignment of native libraries
run: python3 scripts/check-16kb-alignment.py app/build/outputs/apk/debug/app-debug.apk
# Cobertura (JaCoCo do AGP, ligada por -Pcoverage): mapa do que nenhum teste de unidade toca, no resumo do job e no
# artefato unit-evidence (HTML). Não é meta de número.
- name: Unit tests and coverage
run: ./gradlew --no-daemon -Pcoverage testDebugUnitTest createDebugUnitTestCoverageReport
- name: Coverage summary
if: ${{ !cancelled() }}
run: python3 scripts/coverage-summary.py app/build/reports/coverage/test/debug/report.xml
# Lint só com a checagem de versão de API (app/build.gradle.kts): chamada a API mais nova que o minSdk sem conferir a
# versão quebra em aparelho antigo, e o emulador do gate (Android 14) nunca vê. Roda mesmo com teste de unidade falhando.
- name: API level lint
if: ${{ !cancelled() }}
run: ./gradlew --no-daemon :app:lintDebug
- name: Unit test evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: unit-evidence
path: app/build/reports/tests/
path: |
app/build/reports/tests/
app/build/reports/coverage/test/debug/
app/build/reports/lint-results-debug.html
if-no-files-found: warn
retention-days: 14

Expand Down
70 changes: 70 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Mutation
# Teste de mutação (PIT, tarefa :app:pitest em app/build.gradle.kts): muda a lógica pura de propósito e roda os testes de
# unidade contra cada mudança; o resumo do job lista por classe quanto os testes percebem e cada mutante que sobreviveu.
# Não bloqueia PR e não faz parte da regra da main — é mapa de onde os testes são fracos, para ler de tempos em tempos.
#
# Rodar: toda segunda de manhã sozinho, ou Actions → Mutation → Run workflow (com alvos próprios, se quiser).
# Também por push numa branch `ci/mutacao…`, para medir uma mudança antes de mesclar.
on:
schedule:
- cron: '0 9 * * 1'
push:
branches: ['ci/mutacao**']
workflow_dispatch:
inputs:
targets:
description: Classes do PIT, separadas por vírgula (ex. com.keyflow.overlay.*); vazio = os alvos padrão do build
default: ''
permissions:
contents: read
concurrency:
group: mutation-${{ github.ref }}
cancel-in-progress: true
jobs:
pitest:
name: Mutation (PIT)
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '17'
# O mesmo preparo do job de unidade do checks.yml (caminho do JDK e keystore de debug fixos no build).
- name: JDK path and debug keystore
run: |
sudo ln -sfn "$JAVA_HOME" /usr/lib/jvm/java-17-openjdk
mkdir -p ~/.config/.android
keytool -genkeypair -keystore ~/.config/.android/debug.keystore -storepass android \
-keypass android -alias androiddebugkey -dname "CN=Android Debug,O=Android,C=US" \
-keyalg RSA -keysize 2048 -validity 10000
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: sherpa-onnx AAR version
id: sherpa
run: |
version=$(sed -n 's/^val sherpaOnnxVersion = "\(.*\)"$/\1/p' app/build.gradle.kts)
sha=$(sed -n 's/^val sherpaOnnxSha256 = "\(.*\)"$/\1/p' app/build.gradle.kts)
test -n "$version" && test -n "$sha"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: app/libs/sherpa-onnx-${{ steps.sherpa.outputs.version }}.aar
key: sherpa-onnx-${{ steps.sherpa.outputs.version }}-${{ steps.sherpa.outputs.sha }}
- name: Mutation testing
env:
TARGETS: ${{ inputs.targets }}
run: ./gradlew --no-daemon :app:pitest ${TARGETS:+"-PpitTargets=$TARGETS"}
- name: Mutation summary
run: python3 scripts/mutation-summary.py app/build/reports/pitest/mutations.xml
- name: Mutation report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mutation-report
path: app/build/reports/pitest/
if-no-files-found: warn
retention-days: 30
51 changes: 51 additions & 0 deletions .specs/codebase/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,49 @@ Estratégia escolhida: **cobertura completa** (unit + instrumented). Greenfield
- Teste que precisa agir DENTRO de uma janela de tempo do produto (ex.: desfazer o cancelamento, 3 s) alonga a janela por um
gancho `…ForTest` em vez de correr contra o relógio: no emulador do CI um quadro do teclado chega a 1–2 s.

## Técnicas além do teste de exemplo (2026-10-10)

Código que testa o código de outro jeito — cada uma pega uma classe de defeito que o teste "dada esta entrada, espero esta
saída" deixa passar.

| Técnica | Onde | O que pega | Roda |
| ------- | ---- | ---------- | ---- |
| Regras de arquitetura | `ArchitectureRulesTest` (lê o fonte como texto) | Convenção que já custou CI: `collectAsState` direto, `runBlocking` novo, `stopService` no OverlayService, teste que grava sem parar o serviço, espera às cegas pelo "🎙", `}` solto em Regex do ICU. Exceção = entrada na lista do teste, com o porquê | unit |
| StrictMode | `StrictModeGuard` (debug) + `StrictModeKeyboardTest` | Disco/rede na thread principal ao abrir o teclado (achou 5; zeradas). Violação nova falha; `KNOWN` só encolhe | gate (instrumentado, API 28+) |
| Cobertura | `-Pcoverage` (JaCoCo do AGP) + `scripts/coverage-summary.py` | Mapa do que nenhum teste de unidade toca | job de unitários |
| Lint de versão de API | `./gradlew :app:lintDebug` (só `NewApi`, `lint {}` no `app/build.gradle.kts`) | Chamada a API mais nova que o `minSdk` 24 sem conferir a versão — quebra em aparelho antigo e o gate (Android 14) não vê. Pegaria o `registerFrameCommitCallback` do diagnóstico do teclado (Android 7–9) | job de unitários |
| Propriedade | `app/src/test/…/properties/` (kotest-property) | Regra que vale para TODA entrada: barras/grades ida e volta, teclas contíguas e sem buraco (achou a fresta de 0,00006 px na borda direita), orçamento e quadros da orbe, repetição de tecla | unit |
| Fuzz | `app/src/test/…/fuzz/` | Entrada de fora nunca derruba o app: pacote de skin (bytes, zip corrompido, nomes hostis, SVG), manifest, barras, credenciais, argumentos do agente (~30 mil entradas, ~4 s) | unit |
| Máquina de estados | `OverlayCoordinatorStateMachineTest` | Sequências aleatórias de gestos/intenções/cancelar/desfazer/troca de campo/limite com relógio virtual: o microfone capta se e só se o estado é RECORDING, nunca liga duas vezes, a tela mostra o estado real, sempre volta ao repouso. Achou o RESUME em SENDING que deixava o microfone aberto em repouso | unit |
| Mutação | `./gradlew :app:pitest` + `scripts/mutation-summary.py` + `.github/workflows/mutation.yml` | Onde os testes rodam o código mas não conferem o resultado (mutante que sobrevive) | semanal / manual |
| Perfil de base + benchmark | módulo `:baselineprofile` (`BaselineProfileGenerator`, `OpeningBenchmarks`) + `profileinstaller` no app | Abertura a frio do app e do teclado com e sem o perfil (o caminho compilado de antemão na instalação, sem JIT) | à mão, no emulador |

- kotest-property no JUnit4: `fun x() = runBlocking<Unit> { checkAll(...) }` (método JUnit4 tem de devolver Unit). A falha
traz a semente ("Repeat this test by using seed …") e a entrada já encolhida; para repetir, `checkAll(PropTestConfig(seed =
…), …)`. O gerador tem de respeitar as normalizações do código (ex.: o codec das barras troca rótulo vazio por "⌗") — senão
a propriedade falha por culpa do teste.
- Fuzz não usa Jazzer (pede JUnit5): os geradores do kotest bastam, com JSON válido mutado e aninhamento fundo.
- Mutação pela linha de comando do PIT (o plugin Gradle para Android não acompanha o AGP 9): a tarefa reusa o classpath do
`testDebugUnitTest` e só muta o jar de classes do app. Alvos padrão no `app/build.gradle.kts` (lógica pura bem coberta);
outros com `-PpitTargets='com.keyflow.overlay.*'`. Sobrevivente ≠ bug: às vezes a mudança é equivalente; quase sempre é uma
regra que nenhum teste confere. 1ª medição (2026-10-10, local, 7 min): força 81% (1890 de 2327 mutantes cobertos), 437
sobreviventes — ex.: apagar o `publishTime()`/`timeFreeze()` do `OverlayCoordinator` passa, nenhum teste confere o
cronômetro da gravação (`onRecordingTime`).
- Perfil de base: `ANDROID_SERIAL=emulator-5554 ./gradlew :app:generateBaselineProfile` grava
`app/src/release/generated/baselineProfiles/` (versionado; gerar de novo quando o caminho de abertura mudar). Medir:
`ANDROID_SERIAL=… ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest`. **Só emulador**: instala a variante
release (`nonMinifiedRelease`/`benchmarkRelease`, mesmo `com.keyflow`, keystore de debug) — num aparelho com o app de debug,
a troca apaga os dados; sem `ANDROID_SERIAL`, o Gradle roda em TODOS os conectados (o A32 inclusive). O teclado é medido
pelas seções de trace do `KeyFlowKeyboardService` (`KeyFlow.keyboardOpen` do 1º passo do ciclo de mostrar ao 1º quadro
entregue, Android 10+; `KeyFlow.onCreateInputView`; `KeyFlow.onStartInputView`), que também aparecem num trace do Perfetto.
O campo fica numa Activity do APK de benchmark: o teclado abre sobre outro app, com o processo do KeyFlow frio. O
`killProcess()` do Macrobenchmark é um force-stop, que tira o KeyFlow de teclado ativo: o `ime set` vem DEPOIS dele, e a
volta confere que a janela aberta é do uid do KeyFlow (antes, metade das voltas media o Gboard).
1ª medição (2026-10-10, emulador API 34, mediana sem → com perfil): `onCreateInputView` 22 → 8,5 ms no emulador rápido e
65 → 16 ms com a cota de 300% (≈ CI); a abertura até o 1º quadro fica em ~340–390 ms (rápido) e ~990 ms (lento) com ou sem
perfil — esse resto é sistema e renderização (swangle, por software), não o nosso código; app a frio 1,0 s / 2,3 s, −3 a −5%
(ruído). Os traces do Perfetto de cada volta ficam em `baselineprofile/build/outputs/connected_android_test_additional_output/`.

## CI (GitHub Actions) — `.github/workflows/checks.yml`

Roda em PR para `main`, push em `main`, merge queue e manual (`workflow_dispatch`), no padrão do
Expand All @@ -70,6 +113,10 @@ DeckLock (actions fixadas por SHA, `permissions: contents: read`, evidência env
Android e a janela fica em transição (tamanho de Dialog, altura da tela, no topo, 126 px abaixo).
- Toque injetado em emulador lento pode passar dos 250 ms da tira de acentos e virar "segurar": teste que só toca usa
`accentHoldMsForTest` (os da tira de acentos, não).
- Cobertura dos testes de unidade: `./gradlew -Pcoverage createDebugUnitTestCoverageReport` (JaCoCo do AGP, só com a
propriedade) → `app/build/reports/coverage/test/debug/`; `scripts/coverage-summary.py` resume por pacote. O CI faz isso no
job de unitários, depois de publicar os APKs (fora do caminho crítico), e põe a tabela no resumo do job. Mapa do que nenhum
teste de unidade toca, não meta: telas, teclado e serviços são cobertos pelos instrumentados.
- `scripts/ime-placement.py --ime keyflow|gboard [--cold]` mede de FORA do app (adb) onde a janela do teclado fica ao abrir
— sem teste no processo. A frio, no emulador lento: KeyFlow 6/8 aberturas em transição, Gboard 1/8 (desempenho da
abertura a frio é tarefa aberta).
Expand All @@ -94,6 +141,10 @@ DeckLock (actions fixadas por SHA, `permissions: contents: read`, evidência env
30 aberturas, contra 3,78 → 3,98 GB no 37.1.11) — no runner de 7 GB, swap e QEMU travado na galeria.
`host.txt` na evidência registra memória, swap, carga e RSS do QEMU a cada 20 s; subir de versão =
repetir o A/B antes.
- O script do gate encerra o emulador ele mesmo no fim (`emu kill`, até 30 s de espera, `kill -9` no QEMU/netsimd/crashpad
que sobrar, com o nome no log). Em 2026-10-10 um shard todo verde ficou 35 min parado depois do "Terminate Emulator" do
action, até o timeout de 45 min do passo — causa não provada. Se a linha "Processos do emulador vivos…" aparecer num log,
é o caso anormal: guardar. Passo do gate parado sem saída depois de "Concluído: N PNGs" = isso, não teste.
- **Renderizador `swangle_indirect`**, não `swiftshader_indirect`: neste o QEMU caía na RenderThread do host
ao desenhar a prévia do Aero (tela de skins). No swangle, 0 quedas em 40 aberturas. **Todos os goldens
são capturas `swangle`** (2026-10-10): as do CI saem byte a byte iguais às do emulador local (180/180
Expand Down
Loading