PlayerPuzzle

Atividade gamificada Match-3 RPG por turnos para o Moodle — documentação completa.

English | Português

View on GitHub Download .zip

🧪 Testes Automatizados

O PlayerPuzzle vem com uma suíte PHPUnit extensa cobrindo o motor de combate, o fluxo de questões, a segurança anti-trapaça, a integração com o PlayerHUD, backup/restore, conclusão de atividade e privacidade. Todo push no CI roda a matriz completa (Moodle 4.5 → 5.3, PHP 8.1 → 8.4, PostgreSQL e MariaDB).

PHPUnit — Testes Unitários e de Integração (tests/)

Arquivo de teste Casos O que é coberto
access_test.php 1 mod/playerpuzzle:managequestions declara RISK_SPAM, não RISK_XSS — o texto de pergunta/resposta que ela libera pra autoria é sempre limpo na saída (noclean => false, uma chamada simples de format_text())
backup_restore_test.php 13 Duplicar uma atividade copia tentativas/linhas aninhadas e seu próprio banco de questões com ids novos (guarda de regressão pra uma chamada prepare_activity_structure() que faltava no restore); um backup/restore de curso completo remapeia a pergunta registrada de uma tentativa e o currentquestionid pro novo id da questão do banco (caindo pra 0 quando não mapeado), carrega arquivos embutidos na questão, regenera o token da tentativa (nunca os bytes originais, que carregam um índice UNIQUE no site inteiro), preserva toda coluna configurada e os dados de cada tentativa, preserva uma compra real de loadout feita via buy_stock, remapeia a categoria de questões, e remapeia corretamente (ou descarta, se estrangeira) uma referência a item do PlayerHUD dependendo se aquele bloco viajou junto no backup
generator_test.php 1 O create_attempt() do gerador de dados de teste fixa uma seed escolhida e respeita campos opcionais (versão do motor, fase, status) — os cenários Behat dependem dele, então um gerador quebrado é pego aqui primeiro
lib_completion_info_test.php 6 playerpuzzle_get_coursemodule_info() popula customdata com os dois limiares de regra de conclusão só quando o rastreamento é automático (vazio caso contrário, false pra uma instância ausente); playerpuzzle_get_completion_active_rule_descriptions() inclui a descrição de cada regra configurada, pula uma desativada, e degrada com segurança sem nenhum customdata
lib_crud_test.php 8 add_instance()/update_instance() persistem campos e normalizam um gradepass nulo (enviado por standard_grading_coursemodule_elements() quando deixado em branco) pra 0 nos dois caminhos; delete_instance() propaga pras tentativas, pro próprio banco de questões da instância e pro estoque de loadout, e retorna false pra um id desconhecido
lib_grade_item_update_test.php 4 Adicionar uma instância cria um grade_item real no boletim, escalado pra Nota Máxima configurada; gradepass é aplicado diretamente no grade_item (grade_update() descarta silenciosamente da própria lista de permissão); 'reset' limpa as notas registradas sem apagar o item; apagar a instância remove o grade_item
lib_pluginfile_test.php 5 Rejeita um contexto que não é de módulo, uma filearea desconhecida, e um itemid de questiontext/answertext pertencente à questão de outra instância; retorna false (não um erro) pra um arquivo ausente numa questão válida e própria
lib_supports_test.php 2 playerpuzzle_supports() retorna o valor declarado pra toda funcionalidade conhecida e null pra uma não reconhecida — a mesma chamada também fataria se algum constante referenciado fosse usado incondicionalmente, então isso também serve de guarda pra FEATURE_MOD_OTHERPURPOSE, só checada quando de fato definida (Moodle 5.1+)
lib_update_grades_test.php 4 Limpa a nota obsoleta de um estudante específico assim que sua última tentativa é removida; ignora tentativas de Demo; recalcula a partir do progresso restante mais distante quando outras tentativas ainda existem; recalcula a nota de todo mundo quando $userid = 0
mod_form_test.php 11 A seção do PlayerHUD aparece/some conforme a presença do bloco no curso e preserva um item configurado obsoleto como opção selecionável; gamemode tem padrão Campanha; os campos exclusivos de Campanha (maxlevels, maxattempts) e os exclusivos de Partida Única (max_single_matches, grademethod) registram o hideIf certo nos dois sentidos; minquestions tem padrão 3; considererrors desativa quando minquestions é 0; os campos de quantidade de custo de retentativa/prêmio de vitória do PlayerHUD somem quando o item não está configurado, e a validação rejeita um item configurado com quantidade abaixo de 1
phaser_loading_test.php 2 Guarda de regressão estrutural: play.php nunca enfileira phaser.min.js como <script> estático (que poderia competir com core_message/message_drawer.js no rodapé da página), e game_boot.js o carrega dinamicamente em vez disso
uninstall_test.php 2 xmldb_playerpuzzle_uninstall() apaga só as linhas de user_preferences prefixadas com mod_playerpuzzle, deixando as demais (inclusive de outros plugins) intactas, e é um no-op inofensivo quando nada bate
Subtotal 59  

Testes de Conclusão (tests/completion/)

Arquivo de teste Casos O que é coberto
custom_completion_test.php 11 As duas regras de conclusão personalizadas (completionattempts, completionwins): incompleta abaixo do limiar, completa no limiar independente do status final (tentativas) ou só numa vitória (vitórias), ignorando tentativas em andamento e de Demo nos dois casos; cada regra reporta disponibilidade de forma independente; as duas regras são declaradas, descritas com suas contagens configuradas, e ordenadas depois das duas regras nativas
Subtotal 11  

Testes de Eventos (tests/event/)

Arquivo de teste Casos O que é coberto
game_completed_test.php 3 Uma vitória dispara game_completed com o status/modo de jogo vencedor, uma derrota com o status perdedor, e um replay rejeitado (token já consumido) nunca dispara um segundo evento
game_started_test.php 2 Iniciar uma tentativa totalmente nova dispara game_started com os dados esperados; retomar uma tentativa já em andamento não dispara um segundo
Subtotal 5  

Testes de Web Services (tests/external/)

Arquivo de teste Casos O que é coberto
advance_phase_test.php 26 Incremento de fase dentro do nível e virada de nível, com HP escalado pra nova fase; rotação de token com rejeição de replay; uma vitória não verificável reinicia a fase (uma reinicialização contada) enquanto uma derrota verificada é reportada como perdida; uma vitória verificada respeita a dificuldade escolhida (fator de moeda bancado) e grava a escolha de dificuldade da próxima fase; a tentativa continua inprogress e nunca é duplicada ao longo de várias vitórias encadeadas; rejeita avançar além da última fase; credita PuzzleCoin e o item de prêmio de vitória (com e sem PlayerHUD configurado), zera o ledger de moedas e as contagens de uso de consumíveis por fase, limpa o checkpoint de combate e reseeda o estado de replay anti-trapaça; rejeita um token desconhecido e uma tentativa de Demo de forma categórica; aplica a capability de visualização; o backstop de mínimo de perguntas bloqueia/permite avançar; um replay anti-trapaça real de ponta a ponta deriva o valor bancado a partir de uma seed/log conhecida, nunca da alegação do cliente; o bank acontece sob o mesmo lock de tentativa+estoque que uma compra no Lobby usa, e um lock ocupado não muda nada
buy_stock_test.php 10 Uma compra debita PuzzleCoin no preço certo e credita estoque (com e sem PlayerHUD configurado), acumula numa compra repetida, rejeita um saldo insuficiente (incluindo um que nunca ganhou moeda nenhuma) e um tipo inválido, é isolada por instância/usuário, aplica a capability de visualização, é feita sob o mesmo lock de estoque que operações de combate usam, e rejeita a conta de visitante (que só joga a Demo gratuita)
draw_question_test.php 8 Uma tentativa nova sorteia e persiste uma questão sem vazar correção (Blind JSON); reserva a mesma questão aberta até ser genuinamente consumida por validate_answer, aí sim sorteia uma nova; reporta indisponível sem questões aprovadas; rejeita um token desconhecido; aplica a capability de visualização; guarda os eventos pendentes do cliente antes de sortear
generate_questions_test.php 2 Só o que é alcançável sem um provedor de IA real: nega um usuário sem mod/playerpuzzle:managequestions, e rejeita um tema vazio antes de qualquer chamada de IA
get_phase_questionlog_test.php 5 Retorna o log de questões da fase atual da tentativa, escopado pra nunca vazar respostas de outra fase; vazio quando nada foi respondido; rejeita um token desconhecido; aplica a capability de visualização
save_combat_state_test.php 14 Um checkpoint válido persiste e decodifica de volta exatamente, com um mais recente sobrescrevendo o anterior em vez de acumular; rejeita um grid de tabuleiro de tamanho errado, um valor de turno inválido e um token desconhecido; aplica a capability de visualização; o log de eventos (movimentos e eventos de resolução de questão) é armazenado de forma cumulativa, um reenvio só armazena o que é genuinamente novo (sem duplicação, sem perda), um lote começando depois da contagem armazenada é rejeitado (deixaria um buraco), um lote acima do teto de tamanho por checkpoint ou do orçamento de eventos da fase inteira é rejeitado de cara, e uma coordenada de movimento fora dos limites é rejeitada
save_generated_questions_test.php 4 Nega um usuário sem managequestions; salva um lote válido revisado por IA como source=ai/approved=0, pula uma entrada malformada sem falhar o resto do lote, e escopa o salvamento estritamente à instância certa
save_progress_test.php 29 Vitória credita PuzzleCoin e o item de prêmio de vitória (com/sem PlayerHUD, lendo corretamente max_single_matches no modo Partida Única pra checagem de suppressxp); uma derrota nunca concede o item de prêmio e descarta qualquer ouro alegado; a pontuação é sempre contra o HP do chefe escalado por fase/dificuldade, nunca a alegação bruta do cliente, seja a vitória/derrota totalmente ou só parcialmente verificada; rejeita um token desconhecido ou reproduzido; aplica a capability de visualização; o backstop de mínimo de perguntas rejeita uma vitória prematura (nunca bloqueando uma derrota) e permite uma assim que atingido; uma vitória não verificável reinicia a fase (e conta como perda quando as reinicializações se esgotam); uma vitória de Demo nunca banca moedas nem atualiza o boletim; o ganho de moeda do próprio chefe é líquido contra o do jogador antes do pagamento; terminar uma tentativa limpa seu checkpoint de combate e recalcula completionwins imediatamente; um replay anti-trapaça completo (seed/log conhecidos, incluindo eventos carregados só pela chamada final) deriva o valor real bancado contra a alegação inflada do cliente; uma derrota nunca satisfaz completionwins; o bank acontece sob o mesmo lock de tentativa+estoque que uma compra no Lobby usa, e um lock ocupado deixa a tentativa pra tentar de novo de forma limpa
set_sound_preference_test.php 5 Uma chamada válida persiste a preferência do usuário que chamou; os canais de Música/Efeitos Sonoros/Narração são salvos de forma independente, com narração tendo padrão desativado; rejeita um canal inválido; aplica a capability de visualização
transfer_hud_coins_test.php 8 Uma transferência debita o item do PlayerHUD e credita a mesma quantia em PuzzleCoin, somando (não sobrescrevendo) um saldo já existente; rejeita uma quantia além do saldo do PlayerHUD, uma quantia não positiva, e uma transferência quando nenhum item de moeda do PlayerHUD está configurado; isolada por instância/usuário; aplica a capability de visualização; rejeita a conta de visitante
use_stock_test.php 15 Um uso bem-sucedido debita 1 unidade e registra; rejeita usar um tipo que o estudante não possui nenhuma unidade, e rejeita assim que o limite fixo por fase de um tipo (Escudo: 1, Poção/Espada: 3) é atingido; um uso de dica revela o texto e debita estoque de dica, rejeitando uma questão sem dica ou de outra instância; uma tentativa de Demo usa consumíveis de graça sem tocar no estoque real, mesmo pra um tipo nunca possuído, ainda respeitando o limite por fase; rejeita um tipo inválido e um token desconhecido; aplica a capability de visualização; o débito acontece sob o mesmo lock de tentativa+estoque, e um lock ocupado não muda nada
validate_answer_test.php 19 Respostas certas/erradas são reportadas com precisão (a errada carrega o id certo da correta pra feedback); nenhuma questão aberta e uma questão de outra instância ou não aprovada são todas rejeitadas; aplica a capability de visualização; rejeita um token desconhecido e qualquer parâmetro questionid fornecido pelo cliente de cara (o parâmetro foi removido, não só ignorado); a resposta de um jogador é registrada com o texto real da resposta escolhida (nunca o texto de um answerid forjado de outra questão); o palpite do chefe sorteado pelo servidor respeita a precisão configurada por dificuldade/tipo e consome a questão aberta sem tocar nos contadores de questão do jogador; uma resposta do jogador incrementa questions_total/questions_correct e a resposta carrega o total contado pelo servidor; toda resposta julgada (jogador ou chefe) é armazenada como o próprio resultado do servidor pro replay, incluindo uma questão que sumiu mas estava aberta armazenada como errada, enquanto nenhuma questão aberta não armazena resultado nenhum
Subtotal 145  

Testes de Formulário (tests/form/)

Arquivo de teste Casos O que é coberto
import_form_test.php 3 O select de categoria é populado com exatamente as categorias dadas, na mesma ordem, passadas por format_string(); renderiza sem nenhuma opção quando não há nada importável; o aviso estático e o botão de envio com rótulo customizado estão presentes
question_form_test.php 11 initial_repeat_count() nunca fica abaixo de 5 e cresce pra mostrar toda resposta existente; uma questão nova renderiza exatamente 5 opções de múltipla escolha, uma editada com mais respostas existentes renderiza o suficiente pra mostrar todas; o rádio Verdadeiro/Falso some pra Múltipla Escolha e os elementos exclusivos de Múltipla Escolha (adicionar opção, dica de resposta certa, rádio mccorrect) somem pra Verdadeiro/Falso; a validação rejeita um texto de questão vazio (mas não um com só uma imagem embutida), exige pelo menos duas opções preenchidas em Múltipla Escolha, rejeita uma opção marcada como certa que está ela mesma vazia, e passa de forma limpa pra um envio válido de Verdadeiro/Falso ou Múltipla Escolha
Subtotal 14  

Testes de Lógica de Negócio Local (tests/local/)

Arquivo de teste Casos O que é coberto
ai_question_generator_test.php 19 Sem banco de dados nem rede: parse_and_validate() molda uma entrada bem-formada de múltipla escolha/verdadeiro-falso, degrada pra um resultado vazio com JSON malformado ou faltando o envelope "questions", remove cercas de código markdown, descarta uma entrada de múltipla escolha com a contagem errada de respostas certas ou poucas opções, uma entrada verdadeiro-falso sem "answer", um tipo desconhecido ou um enunciado vazio, pula uma entrada que não é array, respeita o limite pedido, e remove HTML embutido de texto não confiável vindo da IA; validate_shaped_question() (o mesmo portão de segurança aplicado a uma prévia reenviada pelo professor) aceita uma questão de múltipla escolha válida e rejeita um tipo desconhecido, a contagem errada de respostas certas, opções demais e texto de resposta em branco; build_prompt() inclui o tema/contagem/idioma e os dois tipos suportados
attempt_consumables_test.php 7 Um tipo nunca usado retorna 0; registrar um uso cria e depois incrementa a linha, escopado por tentativa e nunca vazando entre tentativas; reset_attempt() limpa todo tipo só daquela tentativa; get_uses_by_type() retorna a contagem real de todo tipo numa única leitura em lote; o limite fixo por fase de cada tipo (ou nenhum, pra Dica) é respeitado exatamente na sua própria contagem
attempt_questions_test.php 1 Registra três respostas em duas fases e confere que get_phase_log retorna só as linhas da fase pedida, mais antigas primeiro, no formato do template
coin_ledger_test.php 4 sync() eleva os totais ganhos/ganhos-do-chefe até o valor creditado e nunca diminui um valor já armazenado; available() desconta a parte do chefe da do jogador, nunca negativo; reset() zera as duas colunas
combat_state_test.php 5 Um grid de tabuleiro do tamanho exato é válido, um de formato errado não é; encode()/decode() fazem round-trip do grid e dos medidores intactos; decode() retorna null (não um erro) pra um snapshot ausente ou JSON malformado
game_page_service_test.php 27 check_attempt_limit(): 0 é sempre ilimitado, Campanha conta contra maxattempts e Partida Única contra max_single_matches de forma independente, ignorando tentativas em andamento e de Demo; check_retry_cost(): a primeira tentativa é sempre grátis, a quantidade configurada cobra a partir da 2ª, bloqueia quando insuficiente, continua grátis sem item/sem bloco PlayerHUD/enquanto retoma/só com histórico de Demo; build_game_config(): resolve o HP escalado pra fase (Partida Única sempre usa o HP base), aplica dificuldade só no chefe (mantida numa retomada, ignorando um pedido posterior do Lobby), uma Demo sempre luta no HP fixo independente da config da instância, repassa minquestions/questionstotal (carregado numa retomada) e o estoque de consumíveis possuído/preferência de fala/rngseed+moveseq, uma tentativa nova não carrega checkpoint, uma tentativa real retomada é reconstruída pelo próprio replay do servidor em vez de confiar no último checkpoint do cliente (armazenando qualquer evento que a reconstrução teve que adicionar), enquanto uma Demo retomada simplesmente mantém seu próprio checkpoint; check_guest_demo_only() mantém a conta de visitante restrita à Demo sem afetar um estudante real
grade_calculator_test.php 21 Nenhuma tentativa não gera nota nenhuma; Campanha: uma campanha totalmente vencida tira 100%, uma fase ainda sendo disputada/perdida nunca conta a si mesma, perder a primeira fase tira 0, cruzar um limite de nível ainda credita todo o nível anterior, a nota reflete o progresso mais distante entre todas as tentativas (não só a última), escala pela Nota Máxima configurada (0 sem nota configurada, nunca uma divisão), e Considerar Erros mistura progresso com precisão agregada exatamente quando minquestions permite (nunca dividindo por zero); Partida Única: cada uma das cinco fórmulas de grademethod (mais alta/média/primeira/última/média-de-todas, a última dividindo pelo máximo configurado ou caindo pra outra conta quando Ilimitado) é verificada, uma derrota sempre pontua 0 mesmo com Considerar Erros ligado, uma vitória é ponderada pela própria precisão daquela partida, e uma tentativa em andamento ou nada finalizado não contribui nota nenhuma
hud_service_test.php 18 A busca da instância do bloco é nula sem um bloco PlayerHUD, correta quando um existe, e ignora um bloco em outro curso; is_installed()/is_outdated() refletem a presença real da classe/definição; is_available_for_course() exige uma instância de bloco de verdade, não só o plugin instalado; get_items_for_block() retorna só itens habilitados, ordenados; get_item_name() resolve pro próprio bloco do item e fica vazio pra um estranho; get_upgrade_level() reflete o mesmo saldo que credit_coins() escreve; credit_coins()/grant_item() funcionam pra uma quantidade real e são no-ops seguros pra um item não configurado ou uma quantidade não positiva, com grant_item() não afetado pela flag suppressxp
lobby_page_service_test.php 22 Os campos base do Lobby (URL/sesskey de Jogar, a loja de loadout) sempre aparecem independente do PlayerHUD; a caixa de narração e o saldo/preços de PuzzleCoin refletem o estado real do estudante; o widget de transferência só aparece com um item de moeda do HUD configurado; o progresso de Campanha só aparece com uma tentativa real em andamento (nunca em Partida Única, nunca por uma Demo esquecida em andamento), mostrando corretamente onde a próxima tentativa retoma depois de uma derrota (e nenhuma linha de retomada depois de vencer a campanha inteira) e sempre oferecendo “Jogar Demo”; o aviso de Mínimo de Perguntas só aparece quando configurado; o seletor de dificuldade oferece três opções quando nada está em andamento e mostra a dificuldade atual em modo somente leitura caso contrário (caindo pra Normal num valor armazenado corrompido); um user agent mobile adiciona mobile=1 à URL de Jogar; o painel de ranking só é montado quando o professor o liga; a conta de visitante recebe a flag de Demo apenas e nenhum ranking
move_log_test.php 20 is_valid() aceita um lote normal, misto ou vazio de eventos de movimento/questão e exatamente o teto de tamanho, rejeita um tipo de evento desconhecido/ausente, lotes acima do teto, uma coordenada de movimento negativa ou fora dos limites, uma coordenada ausente, um lado/resultado de questão inválido (incluindo o formato antigo em que o cliente dizia se a resposta era certa), e só aceita consumíveis de combate (nunca Dica) pra esse tipo de evento; merge() só anexa eventos além da contagem armazenada (seguro contra reenvio, seguro contra buraco); mesclar numa tentativa acompanha moveseq e recusa um resultado acima do orçamento da fase inteira; encode()/decode() fazem round-trip de um lote, codificando vazio como null, e decode() trata entrada quebrada como uma lista vazia
question_bank_sync_test.php 13 Importa uma questão de múltipla escolha de resposta única e uma verdadeiro-falso com suas respostas/correção, copia arquivos embutidos (sem duplicá-los num re-sync) e importa uma questão com mais de 5 opções por completo; pula (e conta) uma questão de múltipla escolha com mais de uma resposta certa; re-sincronizar uma categoria sem mudanças atualiza em vez de duplicar, casado por sourceid, e genuinamente propaga uma edição da origem; uma linha do banco cuja entrada de origem some é desativada de forma suave, nunca apagada de vez (o log de questões respondidas de uma tentativa ainda pode referenciar seu id) e — se aprovada e assim desanexada do sync, e a origem depois reaparece — é deliberadamente reimportada como uma linha nova, não relinkada; nunca toca em questões manuais; um usuário com usemine sem useall só importa as próprias questões autoradas enquanto um usuário com useall ainda importa de todo mundo; rejeita uma categoria fora de todo contexto alcançável; lista uma categoria de um contexto alcançável
question_editor_files_test.php 3 prepare() semeia uma área de rascunho real pra uma linha totalmente nova e carrega um arquivo já armazenado pra dentro da área de rascunho; finalize() move um arquivo de volta pro id real dado (deixando o marcador @@PLUGINFILE@@ intacto) e anexa um arquivo recém-enviado ao rascunho pro id real
question_list_service_test.php 3 Uma questão gerada por IA aguardando revisão mostra o rótulo pendente com um link de aprovar funcional; uma questão do banco desativada pelo sync mostra o rótulo desativada com um link “Reativar” em vez disso; uma questão aprovada (qualquer origem) mostra o rótulo aprovada
questions_repository_test.php 21 add_question() cria uma questão de múltipla escolha com suas respostas em ordem, armazena uma dica vazia como null, e suporta uma origem de IA não aprovada; update_question()/update_question_content() cada um substitui o conteúdo deixando a proveniência (source/sourceid/approved/addedby) intacta, nos respectivos caminhos de edição manual e sync do banco; set_approved()/approve_question() invertem ou aprovam-e-desanexam corretamente (com segurança, pra uma questão de IA que nunca teve sourceid); delete_question()/delete_questions_bulk() removem a questão, suas respostas e (dado um contexto) suas áreas de arquivo, escopados estritamente aos ids possuídos; update_question() purga as áreas de arquivo das respostas antigas antes de substituí-las; get_question() retorna null pra um id ausente ou de outra instância, nunca vazando entre instâncias; get_questions_for_instance()/for_management() escopam, ordenam, paginam e caem pra uma coluna de ordenação segura; get_draw_counts() conta só dentro da instância; approve_questions_bulk()/delete_questions_bulk() só tocam ids que a instância de fato possui
ranking_service_test.php 9 Campanha ranqueia pela distância que uma sequência de vitórias alcançou contra uma base fixa de 100 pontos (independente da própria config de nota da instância), mistura precisão quando erros contam, e empates são desfeitos por menos tentativas; Partida Única soma a pontuação de cada partida finalizada e empates são desfeitos por menos partidas depois menos tempo; partidas de Demo e a equipe nunca aparecem; grupos separados só mostram o grupo do próprio visualizador; as linhas do topo têm teto, com um visualizador ranqueado abaixo ainda vendo sua própria linha; ninguém tendo jogado dá um ranking vazio
replay_credit_test.php 8 Uma vitória não verificável reinicia a fase (sem creditar nada) enquanto restam reinicializações, e conta como derrota creditando só o que o log ainda verifica quando elas se esgotam; uma derrota não verificável é simplesmente aceita, nunca reiniciada; uma alegação de Demo é mantida não verificada por design; uma vitória verificada batendo com a alegação credita silenciosamente, enquanto uma discordando de uma alegação inflada credita o valor derivado e dispara um evento replay_diverged; uma derrota verificada conta como perdida independente da alegação; reiniciar uma fase de fato reseta seu estado (seed nova, log vazio, moedas/usos de consumível zerados) e conta uma reinicialização
report_service_test.php 17 O relatório do estudante fica vazio sem estudantes matriculados, ainda lista um estudante com zero tentativas, e calcula corretamente nota/dano médio no chefe pra uma Campanha vencida, ignorando tentativas em andamento e de Demo e excluindo os próprios visualizadores do relatório; SEPARATEGROUPS escopa tanto as linhas de estudante quanto as estatísticas de questões mais erradas ao grupo do próprio visualizador; questões mais erradas ordenam por taxa de erro; a distribuição de notas agrupa corretamente nas duas fronteiras e ignora tentativas em andamento/Demo; a taxa de conclusão conta corretamente uma vitória genuína (Campanha) ou qualquer partida finalizada (Partida Única), ignora Demo, e é um zero limpo (não um erro de divisão) sem estudante nenhum
sound_preferences_test.php 7 Música/Efeitos Sonoros têm padrão habilitado e Narração desabilitado; set_enabled()/is_enabled() fazem round-trip por canal, independentes entre si, escopados por usuário, com um prefixo de nome de preferência consistente (o mesmo que db/uninstall.php casa)
user_stock_test.php 10 Um tipo nunca comprado retorna 0; credit() cria e depois soma, ignorando uma quantidade não positiva; debit() funciona quando há o suficiente e recusa (sem mudar nada) quando não há o suficiente, nunca foi possuído, ou a quantidade é não positiva; o estoque é isolado por usuário e instância; get_all() retorna a quantidade real de todo tipo numa única leitura em lote; delete_for_instance() remove só as linhas daquela instância
Subtotal 235  

Testes do Motor de Combate (tests/local/engine/)

Arquivo de teste Casos O que é coberto
board_engine_test.php 25 Detecção de combinação horizontal/vertical pra sequências de exatamente 3 e mais longas (nunca só 2), tratando o tipo 0 como real, deduplicando uma célula de interseção compartilhada; is_match_at()/match_run_length_at() reportam corretamente com e sem filtro de tipo; swap_in_grid() é sua própria inversa; evaluate_swap() reporta o tipo combinado e reverte o grid, ou retorna null sem combinação; find_move() encontra uma troca disponível ou corretamente reporta nenhuma num tabuleiro impossível de vencer; pick_type_avoiding_match() nunca completa uma sequência; generate_grid() reproduz um grid salvo fielmente ou sorteia um novo sem combinação inicial; apply_gravity_to_grid() deixa cair peças flutuantes, deixa uma coluna já cheia intacta, e sorteia usando o rng injetado; has_any_match() reflete o tabuleiro; shuffle_grid() bate com o resultado documentado de Fisher-Yates pra uma sequência conhecida e só redistribui tipos existentes; shuffle_until_valid() tenta de novo um embaralhamento inválido
combat_engine_test.php 29 combo_multiplier() é 1.0 na base, escalando linearmente além; medidores de veneno/escudo armam aos 100 (preservando o excedente) e são no-op abaixo disso; phase_limit() retorna o limite fixo por tipo e INF pra Dica; needs_revive()/revive_hp() (metade do máximo, arredondado pra cima) e has_next_phase() (falso fora de Campanha, verdadeiro no meio da campanha, falso na última fase do último nível); resolve_damage() aplica o dano total sem escudo, bloqueia e consome um quando armado, e trava em 0; resolve_poison_tick() causa um tique por rodada e trava em 0; resolve_match_effects() cobre todo tipo de peça (Poção cura só o lado que age, Estrela aumenta seu multiplicador, Escudo/Veneno/Mana somam ao medidor do lado que age, Espada escala pelo grupo de combo, não por peça, Moeda credita o lado que age incluindo o chefe, Mana cruzando 100 dispara uma questão pro lado certo e só então) sem alterar o estado de entrada; resolve_consumable_effect() cobre os efeitos independentes de Poção/Escudo/Magia/Espada
combat_test.php 9 calculate_boss_hp()/calculate_student_hp() batem com todo valor da tabela de exemplos documentada; Nível 1/Fase 1 sempre retorna o HP base sem alteração (a propriedade da qual todo chamador depende); um HP base zero escala pra zero; a tabela de fator de dificuldade (com seu fallback) e apply_difficulty() (um no-op no Normal); a tabela de precisão de palpite do chefe por dificuldade/tipo com seu fallback; preços fixos da loja de consumíveis por tipo com fallback; a constante fixa de HP da Demo
golden_vectors_test.php 5 generate_grid() bate bit a bit com uma execução headless em Node do motor JS real de tabuleiro pra duas seeds diferentes; shuffle_until_valid() e apply_gravity_to_grid() cada um bate com o resultado exato do lado JS pro próprio cenário com seed; uma cascata completa guiada por seed (gera, encontra e aplica o primeiro movimento, depois resolve destruir→gravidade→reconferir até assentar) bate com o lado JS de ponta a ponta
prng_test.php 5 A mesma seed sempre reproduz a mesma sequência, batendo bit a bit com a própria sequência documentada do lado JS; seeds diferentes divergem; todo sorteio cai em [0, 1); uma seed fora do intervalo de 32 bits estoura do mesmo jeito que a coerção do lado JS
question_fetcher_test.php 20 draw_random_question_id() é escopado à instância, retorna null sem questões aprovadas, e nunca sorteia uma não aprovada; get_single_question() nunca vaza correção (Blind JSON), retorna null pra uma questão de outra instância ou não aprovada, e sinaliza se existe dica sem nunca vazar o texto dela; is_answer_correct()/get_correct_answer_id() são escopados por questão, nunca resolvendo entre questões, e tratam uma linha malformada sem nenhuma resposta marcada como certa; get_question_type()/get_question_text()/get_answer_text()/get_answer_ids() resolvem corretamente e degradam pra vazio/null pra um id desconhecido ou de outra questão; get_hint_text() é escopado à instância e só retorna texto pra uma questão aprovada que de fato tem uma
replay_test.php 28 derive() retorna null (nunca uma vitória falsa) pra uma tentativa de Demo, um descompasso de versão do motor, um log vazio, ou um log que não começa com um movimento; resolve um abate real de um único movimento e um consumível registrado aplicado antes do movimento, arredondando o dano da combinação corretamente como o cliente ao vivo faz; retorna null quando um consumível contado está ausente do log ou vice-versa; resolve uma derrota real; uma Dica usada sozinha nunca bloqueia a verificação; um evento de questão ausente, uma troca malformada/impossível, um evento de movimento quebrado, ou uma cascata patológica tornam o replay inconclusivo em vez de entrar em loop ou creditar um resultado falso; resolve uma fase completa de múltiplos turnos incluindo um turno automático do chefe e sua própria questão; o chefe revive no meio da simulação em vez de terminar a partida quando minquestions não foi atingido; os resultados de questão sempre vêm do próprio julgamento do servidor, nunca do log do cliente, inclusive quando a validação em si nunca voltou; tirar um snapshot da posição atual de uma tentativa a partir do log retoma corretamente depois do movimento do jogador, do próprio turno não registrado do chefe, de uma questão aberta (julgada ou não), ou de um consumível contado mas nunca registrado
security_test.php 43 generate_attempt_token() cria um token de 64 caracteres genuinamente único, em andamento, semeando o estado de replay anti-trapaça (uma seed aleatória real, config congelada de chefe/dano/moeda) do zero toda vez — nunca fixo nem misturado com valores obsoletos numa seed repetida; validate_and_consume_token() aplica o contrato completo de anti-replay/isolamento (rejeita replay, usuário errado, instância errada, token desconhecido, e um status inválido como coding_exception) enquanto aceita todo status final real; resume_or_create_attempt_token() cria uma tentativa nova no Nível 1/Fase 1 sem histórico prévio, sinaliza/nunca sinaliza Demo corretamente (seu próprio namespace de retomada separado, sempre começando do zero independente de posição herdada), preserva nível/fase e rotaciona o token numa retomada real sem inserir uma segunda linha, isola por instância/usuário e resolve pra mais recente entre várias linhas obsoletas, mantém ou aplica dificuldade conforme apropriado, carrega questionstotal e um checkpoint de combate decodificado (descartando um deixado com o chefe morto), e retoma no nível/fase certo depois de uma derrota (limitado a um maxlevels desde então reduzido) enquanto começa do zero depois de vencer a campanha inteira, distinguindo corretamente “sem tentativa prévia” de “já em 1/1”; has_inprogress_attempt() reflete uma linha genuína, ignorando tentativas de Demo e status finais; with_locked_user_stock() roda seu callback sob um lock livre
Subtotal 164  

Testes de Privacidade (tests/privacy/)

Arquivo de teste Casos O que é coberto
provider_test.php 36 get_metadata() declara playerpuzzle_attempts e as três preferências de som, e — conferido contra o schema real via $DB->get_columns(), não uma lista fixa de chaves — toda coluna real de playerpuzzle_attempts, playerpuzzle_questions e playerpuzzle_user_stock é declarada ou uma exclusão documentada e justificada (mecânica de replay anti-trapaça, FKs estruturais), enquanto playerpuzzle_question_answers não carrega dado pessoal nenhum; export_user_preferences() exporta só um canal alterado; contextos/usuários são descobertos via tentativas, só estoque de loadout, ou só autoria de questão; export_user_data() carrega dados de tentativa incluindo o log de movimentos e os resultados de questão (diferente dos campos irmãos de anti-trapaça), entre múltiplos contextos, e é um no-op pra um contexto que não é de módulo; a exclusão (por usuário, por vários usuários, e contexto inteiro) remove só as linhas/contextos pretendidos, é um no-op pra contextos que não são de módulo ou um módulo de curso ausente, e — pra autoria de questão especificamente — anonimiza addedby em vez de apagar a questão em si, nos três caminhos de exclusão; uma guarda de regressão confirma que toda consulta faz join por {modules}.name = 'playerpuzzle' pra que um id de instância colidindo com outro tipo de módulo nunca seja confundido com dado deste
Subtotal 36  
Total Geral 669
vendor/bin/phpunit --testsuite mod_playerpuzzle_testsuite

Cobertura

Medida localmente com Xdebug (moodle-coverage, ferramenta de bancada — não faz parte do CI):

  Cobertura
Classes 44,68% (21/47 totalmente cobertas)
Métodos 74,03% (191/258)
Linhas 86,79% (3817/4398)

(Retrato de 24/09/2026, 669 testes.)

A cobertura de linhas é o número mais informativo aqui: a maioria das classes abaixo de 100% de métodos ainda fica em 90%+ de linhas — os métodos descobertos costumam ser ramos de caso extremo (um conjunto de resultados já vazio, uma guarda que todo teste já satisfaz) ou código padrão de uma classe de evento do Moodle (get_url(), init()) que um teste comum de disparar-e-verificar nunca chama, não funcionalidades sem teste. As classes que realmente merecem atenção, com cobertura de linhas real abaixo de 65%:

JavaScript — Testes Unitários (tests/js/engine/)

amd/src/engine/board_rules.js, combat_rules.js e prng.js guardam a matemática determinística de match-3/combate/PRNG que o cliente precisa reproduzir bit a bit contra o próprio motor PHP do servidor (classes/local/engine/) pra que o replay anti-trapaça algum dia concorde com o que foi de fato jogado. Essa matemática é extraída em módulos UMD pequenos e testada sem cabeça (headless) via o test runner nativo do Node — sem Jest, sem navegador, sem runtime nenhum de Moodle/RequireJS/Phaser. O wrapper UMD de cada módulo cai pra um module.exports CommonJS simples fora de um ambiente define(), então um teste faz require() dele direto. Este é o primeiro plugin do ecossistema com uma camada de teste JS própria, então ainda não existe uma convenção da casa a seguir — veja o workflow de CI pra ver exatamente como ele se encaixa no pipeline (passo próprio, restrito à leg runchecks: all junto com o Grunt, já que é uma questão de toolchain, não de versão do Moodle).

Arquivo de teste Casos O que é coberto
board_rules.test.js 25 Espelho em JS de board_engine_test.php: detecção de combinação horizontal/vertical pra sequências de exatamente 3 e mais longas (nunca só 2), tratando o tipo 0 como real, deduplicando uma célula de interseção compartilhada; isMatchAt/matchRunLengthAt com e sem filtro de tipo; swapInGrid é sua própria inversa; evaluateSwap reporta o tipo combinado e reverte o grid, ou retorna null; findMove encontra uma troca disponível ou reporta nenhuma num tabuleiro impossível de vencer; pickTypeAvoidingMatch nunca completa uma sequência; generateGrid reproduz um grid salvo fielmente ou sorteia um novo sem combinação inicial; applyGravityToGrid deixa cair peças flutuantes, deixa uma coluna já cheia intacta, e sorteia usando o rng injetado, nunca Math.random direto; hasAnyMatch reflete o tabuleiro; shuffleGrid bate com o resultado documentado de Fisher-Yates pra uma sequência conhecida e só redistribui tipos existentes; shuffleUntilValid tenta de novo um embaralhamento inválido
combat_rules.test.js 29 Espelho em JS de combat_engine_test.php: escala de comboMultiplier, armamento dos medidores de veneno/escudo (com tratamento de excedente), phaseLimit/needsRevive/reviveHp/hasNextPhase, resolveDamage (bloqueio por escudo, trava em 0), resolvePoisonTick, todo tipo de peça em resolveMatchEffects (Poção, Estrela, Escudo/Veneno/Mana, Espada escalando pelo grupo de combo, Moeda, Mana cruzando 100 disparando questão só pro lado certo) sem alterar o estado de entrada, e os próprios efeitos independentes de Poção/Escudo/Magia/Espada em resolveConsumableEffect
prng.test.js 5 Espelho em JS de prng_test.php: a mesma seed sempre reproduz a mesma sequência, batendo bit a bit com uma sequência exata documentada; seeds diferentes divergem; todo sorteio cai em [0, 1); uma seed fora do intervalo de 32 bits estoura do mesmo jeito que a coerção >>> 0 faria
Subtotal 59  

Esses três arquivos são um espelho deliberado, bit a bit, dos seus equivalentes em PHP, não uma suíte de testes independente: tests/local/engine/golden_vectors_test.php do lado PHP trava fixtures produzidas rodando esses exatos módulos JS sem cabeça através do Node, então uma mudança futura que dessincronize a ordem de sorteio do rng, a ordem de iteração, ou a condição de nova tentativa de qualquer um dos dois lados falha do lado PHP mesmo antes de um descompasso real entre cliente e servidor aparecer.

node --test tests/js/engine/board_rules.test.js tests/js/engine/combat_rules.test.js tests/js/engine/prng.test.js

Behat — Testes de Aceitação

Arquivo de feature Cenários O que é coberto
mod_playerpuzzle_accessibility.feature 4 O tabuleiro acessível (uma <table role="grid"> real espelhando o Canvas — veja Acessibilidade) anuncia de quem é a vez; captura o foco do teclado sozinho, sem precisar de Tab; Space relê os movimentos disponíveis sem agir; uma tecla numérica executa um movimento sem erro
mod_playerpuzzle_gameplay.feature 4 Restrito deliberadamente à moldura do Moodle ao redor da loja de loadout pré-partida — nunca à mecânica de tabuleiro/combate, que roda inteira no Canvas sem seed determinística pra guiar uma partida real sem um cenário instável de tentar-até-bater: o Lobby mostra o saldo de PuzzleCoin e a loja; comprar um consumível debita/credita via AJAX sem recarregar a página; comprar sem PuzzleCoin suficiente mostra um erro em vez de falhar silenciosamente; uma compra de loadout não bloqueia a entrada no jogo
mod_playerpuzzle_match.feature 3 Toda tentativa semeada com a seed PRNG 2, então os movimentos anunciados pelo tabuleiro acessível são fixos e determinísticos (o movimento 4, uma combinação de Espada, sempre bate com o HP configurado do chefe): um movimento vencedor termina em vitória verificada que banca suas moedas; um movimento que deixa o estudante pro turno do chefe termina em derrota verificada; uma vitória que o replay do servidor não consegue verificar reinicia a fase em vez de contar
mod_playerpuzzle_ranking.feature 3 A matemática de ranking de uma campanha de um nível (a fase N credita (N-1)×10 de 100 pontos): o painel de ranking abre no lugar do próprio painel do Lobby e fecha de volta pra ele; um professor pode desligar o ranking; com grupos separados um estudante só vê o ranking do próprio grupo
mod_playerpuzzle_settings.feature 1 Um professor adiciona uma atividade PlayerPuzzle e ela aparece na página do curso
mod_playerpuzzle_smoke.feature 1 Um estudante abre o Lobby e consegue começar o jogo — o fluxo básico da moldura Moodle (adicionar a atividade, chegar ao Lobby, entrar numa partida), deliberadamente sem tocar nos pixels do Canvas
Subtotal 16  
php admin/tool/behat/cli/init.php
vendor/bin/behat --tags=@mod_playerpuzzle --profile=chrome