Repository navigation
Description
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/snippetaceita um parâmetro opcional de exclusão (ex.:exclude=<título>) e o
repassa como 3º argumento depickSnippet. Ausente → comportamento de hoje, inalterado. -
PracticeViewenvia o título do snippet anterior somente quandolanguageedifficulty
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 empickSnippet(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 deexclude(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.tspara 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 testverde. - 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
caioross commentedon Aug 5, 2026
🏛️ 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:26chamapickSnippet(lang.value, difficulty.value)(2 args) ·PracticeView.tsx:43monta a query só comlanguage/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.titleexiste (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.exclusionForemsrc/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 deroute.ts:10promete ("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: opackage.jsonnão declarasideEffects, e o pool é alcançável porresolvePool/pickSnippetno 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 emsrc/lib/(onde vivem todos os testes: não há teste de componente no repo) sem tocar no bundle. (2) O "último snippet" tem de seruseRef, nuncauseState.loadSnippetéuseCallback(…, [])(PracticeView.tsx:35,61) e é dependência do efeito que carrega (:63-65). Guardar o prev em state força ouseCallbacka depender dele → identidade nova a cada load bem-sucedido → o efeito re-dispara → loop infinito deGET /api/snippetem produção. Grave o prev no mesmo ponto em que a resposta é aceita (:52-53), depois do guard defetchSeq(:47), nunca na resposta descartada.excludesó alimenta um!==de strings (snippets.ts:3717) — sem regex, sem índice, sem eco na resposta (route.ts:27devolve só{ok, snippet}); valor desconhecido não é erro, o filtro simplesmente não casa (comportamento já embutido nofrom = 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 usacache:"no-store"(:44). O Treino Livre não persiste nada —PracticeViewnão fala com/api/roomsnem com o Supabase —, então zero superfície de leaderboard: nada aqui pode virar WPM forjado.:200passa pelo mesmoloadSnippet, então herda a regra de graça — é o comportamento certo. Primeiro load não mandaexclude(prev nulo). Nada de animação nova, nada a suprimir sobprefers-reduced-motion(o arquivo declara isso em:16).TypingCoremontar (:207, remontado porkey={round}). O único risco de performance é o do item (1): se@/lib/snippetsentrar no bundle do cliente,/practice— a porta de entrada do Iniciante — engorda dezenas de KB. Prove: compare oFirst Load JSde/practiceno output dopnpm buildantes e depois, e cole os dois números no PR.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:
src/lib/practiceExclusion.ts(novo, cliente-safe, sem importarsnippets.ts):exclusionFor(prev: {lang, diff, title} | null, lang, diff): string | null→ devolveprev.titlesó quandoprev.lang === lang && prev.diff === diff;nullcaso contrário.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).PracticeView.tsx:const prevRef = useRef<{lang: LangId; diff: Difficulty; title: string} | null>(null); em:42-45anexe&exclude=${encodeURIComponent(ex)}só quandoexclusionFor(prevRef.current, lang, diff)não for nulo; em:52-53(resposta aceita) graveprevRef.current = {lang, diff, title: json.snippet.title}. Sem novas deps nouseCallbackde:35-61.api/snippet/route.ts: leiaurl.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.snippets.ts:3699-3701(a frase sobre o Treino Livre chamar com 2 args morreu).pnpm typecheck·pnpm build(+ diff do First Load JS de/practice) ·pnpm test·validate-metrics·validate-persistence. Lembre que o gate real da CI incluipnpm test.Vetores de veto previstos:
excludegigante, 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:excludenã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).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 é ouseCallback/efeito de:35-65: comuseStateno lugar douseRefele latcha em loop de fetch — respondido no passo 3, e é o item que eu pediria para o PR Doctor conferir primeiro.caioross commentedon Aug 5, 2026
Resolvida em PR #130 (non-draft, §7.3).
GET /api/snippetpassou a aceitar?exclude=<título>e repassá-lo como 3º arg depickSnippet; oPracticeViewmanda o título anterior só quando linguagem e dificuldade não mudaram, guardado emuseRefe gravado apenas no ramo em que a resposta é aceita (depois do guard defetchSeq). Docstring depickSnippetatualizado.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.tsnovo (não parasnippets.ts, que tem 96 KB e vazaria ao bundle do cliente), e por consequência os testes da regra nova vivem empracticeExclusion.test.ts, não emsnippets.test.tscomo 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).