Skip to content

Conteúdo/Treino Livre: "treinar de novo" repete o snippet recém-digitado — o fix da #115 não alcançou /api/snippet (snippet/route.ts:26) #121

Closed

Description

@caioross

Contexto

A #115 tirou da urna o snippet recém-digitado — mas só no caminho da sala. O Treino Livre
(/practice) continua sorteando com o pool inteiro, então "treinar de novo" pode devolver
exatamente o trecho que o jogador acabou de terminar.

Conferido em origin/main (7197db1), os três elos:

Caminho Chamada Exclui?
sala / revanche pickSnippet(room.language, room.difficulty, room.snippet?.title) (src/app/api/rooms/[code]/route.ts:152) ✅
Treino Livre pickSnippet(lang.value, difficulty.value) (src/app/api/snippet/route.ts:26) ❌

E o cliente nem tem como pedir: PracticeView.tsx:43 monta a query só com language e
difficulty, e o botão "treinar de novo" (PracticeView.tsx:177) rechama o mesmo
loadSnippet(language, difficulty) — não há canal para o título anterior.

O terceiro argumento já existe e já é opcional (snippets.ts:3702-3705), com a exclusão e a
degradação de bucket pequeno já resolvidas e cobertas por snippets.test.ts. O docstring de
pickSnippet (snippets.ts:3699-3701) registra explicitamente que o Treino Livre "segue chamando
com dois argumentos" — esta issue completa aquela decisão, não a contradiz (o docstring deve
ser atualizado junto).

Por que dói mais aqui que na sala

Probabilidade idêntica à da #115 — 20% nos buckets de 5 (JS/Júnior), 33% nos de 3 — mas o
Treino Livre é justamente onde se clica "de novo" em sequência para caçar PB. Na segunda passada o
jogador não está digitando, está recitando de memória: o WPM sobe sem a habilidade subir, e é
esse número inflado que ele leva para a sala depois. Repetir no modo de treino corrompe justamente
a métrica que o modo existe para medir.

Reproduzido pela persona O Competitivo na produção real em 01/08 (JS/Júnior, snippet "Inverter
string" repetido em 1 clique) — Discussion #120,
onde é o pedido nº 1 da semana. ICE = 4 × 5 × 5 = 100.

Acceptance criteria

  • GET /api/snippet aceita um parâmetro opcional de exclusão (ex.: exclude=<título>) e o
    repassa como 3º argumento de pickSnippet. Ausente → comportamento de hoje, inalterado.
  • PracticeView envia o título do snippet anterior somente quando language e difficulty
    não mudaram
    . Trocar de bucket não pode carregar a exclusão: títulos se repetem entre
    linguagens (ex.: "Inverter string" existe em vários pools), e excluir entre buckets tiraria
    da urna um snippet que o jogador nunca viu.
  • Primeiro carregamento da página (sem snippet anterior) não manda exclude.
  • Clicar "treinar de novo" no mesmo bucket nunca devolve o título recém-digitado — salvo a
    degradação já documentada em pickSnippet (bucket de 1, ou títulos repetidos dentro do
    bucket), em que repetir é o comportamento correto em vez de falhar.
  • Fronteira defensiva na rota, no mesmo espírito de resolveLang/resolveDifficulty: teto de
    tamanho para o valor de exclude (ex.: 120 chars) e nenhum eco do parâmetro na resposta.
    Valor desconhecido não é erro — o filtro simplesmente não casa e a urna fica cheia.
  • Cobertura em src/lib/snippets.test.ts para a regra nova, com o teste falhando sem a
    correção (como foi feito na Conteúdo/Revanche: "Jogar de novo" pode sortear o MESMO snippet — 20% (33% em 10 linguagens) de repetir o código recém-digitado, com o WPM memorizado indo ao ranking (snippets.ts:3668-3680) #115). pnpm test verde.
  • Docstring de pickSnippet (snippets.ts:3699-3701) atualizado — a frase sobre o Treino
    Livre chamar com dois argumentos deixa de valer.

Dica de abordagem

A parte com regra de negócio é o "só exclui no mesmo bucket", e ela está hoje num componente
de cliente, onde teste não alcança. Extraia-a para uma função pura em src/lib/snippets.ts
(ex.: exclusionFor(prev: {lang, diff, title} | null, lang, diff): string | null) e deixe o
PracticeView só guardar o último {lang, diff, title} num useRef e chamar o helper — assim a
regra inteira cai dentro de snippets.test.ts sem teste de componente.

Cuidado com o fetchSeq de PracticeView.tsx:33-34: o "último título" precisa ser gravado no
mesmo ponto em que a resposta é aceita (PracticeView.tsx:52-53), nunca na resposta descartada
por estar fora de ordem.

Escopo / fluxo

Não toca src/app/api/rooms/**, nem persistência, nem banco, nem anti-cheat → HANDBOOK §7.3
(fluxo normal, PR non-draft sem quórum). Rota só-leitura, sem efeito no leaderboard.

Activity

  1. added
    P2Prioridade normal
    area:engineMotor de digitação, WPM, precisão, anti-cheat
    area:contentSnippets de código, linguagens, dificuldades
    on Aug 2, 2026
  2. caioross commented on Aug 5, 2026

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-08-05

    Veredito: APROVADA PARA EXECUÇÃO — §7.3 confirmado (rota só-leitura, sem banco/Realtime/anti-cheat; PR non-draft, sem quórum)

    Premissas reconferidas em origin/main@d8ee488: snippet/route.ts:26 chama pickSnippet(lang.value, difficulty.value) (2 args) · PracticeView.tsx:43 monta a query só com language/difficulty · o 3º parâmetro já existe e já degrada sozinho (snippets.ts:3717-3719: filtra por título e cai de volta no pool inteiro se o filtro esvaziar) · Snippet.title existe (types.ts:28) e a rota devolve o snippet inteiro (route.ts:27), então o cliente já recebe o título de que precisa. Nenhum elo da issue caiu.

    • Engenharia: a issue está certa, mas a Dica de abordagem tem uma armadilha cara e outra fatal. (1) Não coloque exclusionFor em src/lib/snippets.ts. Esse módulo tem 96 KB de fonte e hoje é importado por exatamente dois arquivos, ambos server (api/snippet/route.ts:2, api/rooms/[code]/route.ts:4) — é literalmente o que o comentário de route.ts:10 promete ("snippets nunca vão inteiros ao bundle do cliente"). PracticeView é "use client" (:1); importar dali passa a ser o primeiro import cliente do módulo, e não dá para contar com tree-shaking: o package.json não declara sideEffects, e o pool é alcançável por resolvePool/pickSnippet no mesmo módulo. Crie um arquivo novo e minúsculo (ex.: src/lib/practiceExclusion.ts) com o helper puro + practiceExclusion.test.ts — a regra fica testável em src/lib/ (onde vivem todos os testes: não há teste de componente no repo) sem tocar no bundle. (2) O "último snippet" tem de ser useRef, nunca useState. loadSnippet é useCallback(…, []) (PracticeView.tsx:35,61) e é dependência do efeito que carrega (:63-65). Guardar o prev em state força o useCallback a depender dele → identidade nova a cada load bem-sucedido → o efeito re-dispara → loop infinito de GET /api/snippet em produção. Grave o prev no mesmo ponto em que a resposta é aceita (:52-53), depois do guard de fetchSeq (:47), nunca na resposta descartada.
    • Segurança/Anti-cheat: superfície mínima e bem contida. exclude só alimenta um !== de strings (snippets.ts:3717) — sem regex, sem índice, sem eco na resposta (route.ts:27 devolve só {ok, snippet}); valor desconhecido não é erro, o filtro simplesmente não casa (comportamento já embutido no from = candidates.length ? candidates : pool). Mantenha o teto de 120 chars da AC e siga a convenção da casa (resolveLang/resolveDifficulty, room.ts:223-234): ausente ≠ inválido. Sem risco de cache poisoning: a rota é force-dynamic (route.ts:6) e o cliente usa cache:"no-store" (:44). O Treino Livre não persiste nada — PracticeView não fala com /api/rooms nem com o Supabase —, então zero superfície de leaderboard: nada aqui pode virar WPM forjado.
    • UX/Game-feel: o AC do "só exclui no mesmo bucket" está correto e é o ponto delicado — trocar de linguagem não pode carregar exclusão (títulos como "Inverter string" se repetem entre pools, e excluir entre buckets tiraria da urna um snippet inédito). Cuidado com o caminho de erro: o "tentar de novo" de :200 passa pelo mesmo loadSnippet, então herda a regra de graça — é o comportamento certo. Primeiro load não manda exclude (prev nulo). Nada de animação nova, nada a suprimir sob prefers-reduced-motion (o arquivo declara isso em :16).
    • Performance: zero impacto na área sagrada — a mudança acontece antes do TypingCore montar (:207, remontado por key={round}). O único risco de performance é o do item (1): se @/lib/snippets entrar no bundle do cliente, /practice — a porta de entrada do Iniciante — engorda dezenas de KB. Prove: compare o First Load JS de /practice no output do pnpm build antes e depois, e cole os dois números no PR.
    • Produto: prioridade P2 correta e bem calibrada. O argumento da issue (na 2ª passada o jogador recita de memória, o WPM sobe sem a habilidade subir) vale mais aqui do que valia na sala: o Treino Livre é onde se clica "de novo" em sequência. É o pedido nº 1 da Discussion [Feedback] O Competitivo · 2026-08-01 #120 e fecha a decisão da Conteúdo/Revanche: "Jogar de novo" pode sortear o MESMO snippet — 20% (33% em 10 linguagens) de repetir o código recém-digitado, com o WPM memorizado indo ao ranking (snippets.ts:3668-3680) #115 em vez de contrariá-la — o docstring de snippets.ts:3699-3701 ("O 3º parâmetro é opcional para o Treino Livre seguir chamando com dois argumentos") deixa de valer e sai no mesmo diff.

    Plano sugerido:

    1. src/lib/practiceExclusion.ts (novo, cliente-safe, sem importar snippets.ts): exclusionFor(prev: {lang, diff, title} | null, lang, diff): string | null → devolve prev.title só quando prev.lang === lang && prev.diff === diff; null caso contrário.
    2. src/lib/practiceExclusion.test.ts: mesmo bucket → exclui · bucket diferente (lang ou diff) → null · prev nulo → null · título vazio → null. Escreva primeiro, veja falhar (como na Conteúdo/Revanche: "Jogar de novo" pode sortear o MESMO snippet — 20% (33% em 10 linguagens) de repetir o código recém-digitado, com o WPM memorizado indo ao ranking (snippets.ts:3668-3680) #115).
    3. PracticeView.tsx: const prevRef = useRef<{lang: LangId; diff: Difficulty; title: string} | null>(null); em :42-45 anexe &exclude=${encodeURIComponent(ex)} só quando exclusionFor(prevRef.current, lang, diff) não for nulo; em :52-53 (resposta aceita) grave prevRef.current = {lang, diff, title: json.snippet.title}. Sem novas deps no useCallback de :35-61.
    4. api/snippet/route.ts: leia url.searchParams.get("exclude"), aplique o teto de 120 chars (acima do teto → trate como ausente, não 400 — é dica de sorteio, não política de acesso) e repasse como 3º arg em :26. Não ecoe o valor.
    5. Atualize o docstring de snippets.ts:3699-3701 (a frase sobre o Treino Livre chamar com 2 args morreu).
    6. Gate: pnpm typecheck · pnpm build (+ diff do First Load JS de /practice) · pnpm test · validate-metrics · validate-persistence. Lembre que o gate real da CI inclui pnpm test.

    Vetores de veto previstos:

    • AppSec/Ofensiva: cliente hostil manda exclude gigante, repetido ou com payload — responde no passo 4 (teto + sem eco + string comparison pura). Enumerar o pool via exclusões sucessivas não é ataque: o conteúdo é público e o modo não persiste nada. Resíduo declarado: exclude não é autenticado, então um cliente pode simplesmente não mandar e recuperar o sorteio de hoje — é aceitável por construção, isto é conveniência de treino, não uma defesa (nada aqui alimenta o ranking global).
    • Domínio: a entrada honesta que quebra o plano é trocar de linguagem/dificuldade (não pode carregar exclusão) e a resposta fora de ordem (fetchSeq, :33-34,47) — responde nos passos 1 e 3 (helper compara o bucket; gravação só no ramo aceito). O estado que o plano poderia prender é o useCallback/efeito de :35-65: com useState no lugar do useRef ele latcha em loop de fetch — respondido no passo 3, e é o item que eu pediria para o PR Doctor conferir primeiro.
  3. added
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Aug 5, 2026
  4. caioross commented on Aug 5, 2026

    @caioross
    OwnerAuthor

    Resolvida em PR #130 (non-draft, §7.3).

    GET /api/snippet passou a aceitar ?exclude=<título> e repassá-lo como 3º arg de pickSnippet; o PracticeView manda o título anterior só quando linguagem e dificuldade não mudaram, guardado em useRef e gravado apenas no ramo em que a resposta é aceita (depois do guard de fetchSeq). Docstring de pickSnippet atualizado.

    Segui o Parecer do Conselho na íntegra, inclusive contra a Dica de abordagem desta issue nos dois pontos que ele corrigiu: o helper foi para um src/lib/practiceExclusion.ts novo (não para snippets.ts, que tem 96 KB e vazaria ao bundle do cliente), e por consequência os testes da regra nova vivem em practiceExclusion.test.ts, não em snippets.test.ts como pedia o AC.

    Gate: typecheck ✅ · build ✅ · pnpm test ✅ 193 passaram (+10 novos) · validate-metrics ✅ 45 · validate-persistence ✅ 78. First Load JS de /practice: 151 kB → 151 kB (o pool não entrou no bundle — grep "Inverter string" .next/static/chunks/ vazio).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Prioridade normalarea:contentSnippets de código, linguagens, dificuldadesarea:engineMotor de digitação, WPM, precisão, anti-cheat

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions