PlayerPuzzle

Turn-based Match-3 RPG gamified activity for Moodle — full documentation.

English | Português

View on GitHub Download .zip

🧪 Automated Tests

PlayerPuzzle ships with an extensive PHPUnit suite covering the combat engine, the question pipeline, anti-cheat security, PlayerHUD integration, backup/restore, completion and privacy. Every CI push runs the full matrix (Moodle 4.5 → 5.3, PHP 8.1 → 8.4, PostgreSQL & MariaDB).

PHPUnit — Unit & Integration Tests (tests/)

Test file Cases What is covered
access_test.php 1 mod/playerpuzzle:managequestions declares RISK_SPAM, not RISK_XSS — the question/answer text it unlocks authoring of is always cleaned on output (noclean => false, a plain format_text() call)
backup_restore_test.php 13 Duplicating an activity copies attempts/nested rows and its own question bank with fresh ids (a regression guard for a missing prepare_activity_structure() call in restore); a full course backup/restore remaps an attempt’s logged question and currentquestionid to the new bank question id (dropping to 0 when unmapped), carries embedded question files, regenerates the attempt token (never the original bytes, which carry a site-wide UNIQUE index), preserves every configured column and each attempt’s data, preserves a real loadout purchase made through buy_stock, remaps the question category, and correctly remaps or drops (if foreign) a PlayerHUD item reference depending on whether that block travelled with the backup
generator_test.php 1 The test data generator’s create_attempt() pins a chosen seed and honors optional fields (engine version, phase, status) — the Behat scenarios depend on it, so a broken generator is caught here first
lib_completion_info_test.php 6 playerpuzzle_get_coursemodule_info() populates customdata with both completion-rule thresholds only when tracking is automatic (empty otherwise, false for a missing instance); playerpuzzle_get_completion_active_rule_descriptions() includes each configured rule’s description, skips a disabled one, and degrades safely with no customdata at all
lib_crud_test.php 8 add_instance()/update_instance() persist fields and normalize a null gradepass (submitted by standard_grading_coursemodule_elements() when left blank) to 0 on both paths; delete_instance() cascades to attempts, the instance’s own question bank and loadout stock, and returns false for an unknown id
lib_grade_item_update_test.php 4 Adding an instance creates a real gradebook grade_item scaled to the configured Maximum Grade; gradepass is applied directly on the grade_item (grade_update() silently drops it from its own allow-list); 'reset' clears recorded grades without deleting the item; deleting the instance removes the grade_item
lib_pluginfile_test.php 5 Rejects a non-module context, an unknown filearea, and a questiontext/answertext itemid belonging to a different instance’s question; returns false (not an error) for a missing file on an otherwise valid, owned question
lib_supports_test.php 2 playerpuzzle_supports() returns the declared value for every known feature and null for an unrecognised one — the same call also fatals if any referenced constant were used unconditionally, so this doubles as the guard for FEATURE_MOD_OTHERPURPOSE, only checked when actually defined (Moodle 5.1+)
lib_update_grades_test.php 4 Clears a specific student’s stale grade once their last attempt is removed; ignores Demo attempts; recomputes from the furthest remaining progress when other attempts still exist; recomputes every user’s grade when $userid = 0
mod_form_test.php 11 The PlayerHUD section appears/hides with the block’s presence in the course and preserves a stale configured item as a selectable option; gamemode defaults to Campaign; the Campaign-only fields (maxlevels, maxattempts) and Single Match-only fields (max_single_matches, grademethod) each register the right hideIf in both directions; minquestions defaults to 3; considererrors disables when minquestions is 0; the HUD retry-cost/win-grant quantity fields hide when their item is unconfigured, and validation rejects a configured item paired with a quantity below 1
phaser_loading_test.php 2 Structural regression guard: play.php never queues phaser.min.js as a static <script> (which could race core_message/message_drawer.js in the page footer), and game_boot.js loads it dynamically instead
uninstall_test.php 2 xmldb_playerpuzzle_uninstall() deletes only mod_playerpuzzle-prefixed user_preferences rows, leaving unrelated ones (including other plugins’) untouched, and is a harmless no-op when nothing matches
Subtotal 59  

Completion Tests (tests/completion/)

Test file Cases What is covered
custom_completion_test.php 11 Both custom completion rules (completionattempts, completionwins): incomplete below threshold, complete at threshold regardless of final status (attempts) or only on a win (wins), ignoring in-progress and Demo attempts either way; each rule reports availability independently; both rules are declared, described with their configured counts, and sorted after the two core rules
Subtotal 11  

Event Tests (tests/event/)

Test file Cases What is covered
game_completed_test.php 3 A victory triggers game_completed with the winning status/gamemode, a defeat with the losing status, and a rejected replay (already-consumed token) never fires a second event
game_started_test.php 2 Starting a brand new attempt triggers game_started with the expected data; resuming an already in-progress attempt does not fire a second one
Subtotal 5  

Web Services Tests (tests/external/)

Test file Cases What is covered
advance_phase_test.php 26 Phase increment/level rollover and HP scaling for the new phase; token rotation with replay rejection; an unverifiable victory restarts the phase (one restart counted) while a verified defeat is reported as lost; a verified win respects the chosen difficulty (coin factor banked) and stores the next phase’s difficulty pick; the attempt stays inprogress and is never duplicated across many chained wins; rejects advancing past the final phase; credits PuzzleCoin and the win-grant item (with and without PlayerHUD configured), resets the coin ledger and per-phase consumable-use counts, clears the combat checkpoint and re-seeds the anti-cheat replay state; rejects an unknown token and a Demo attempt outright; enforces the view capability; the minquestions backstop blocks/allows advancing; a real end-to-end anti-cheat replay derives the banked value from a known seed/log rather than the client’s claim; banking happens under the same attempt+stock lock a Lobby purchase takes, and a busy lock changes nothing
buy_stock_test.php 10 A purchase debits PuzzleCoin at the right price and credits stock (with and without PlayerHUD configured), accumulates on repeat purchase, rejects an insufficient balance (including one that never earned any coin) and an invalid type, is isolated per instance/user, enforces the view capability, is taken under the same stock lock combat operations use, and rejects the guest account (which only ever plays the free Demo)
draw_question_test.php 8 A fresh attempt draws and persists a question with no correctness leaked (Blind JSON); re-serves the same open question until it is genuinely consumed by validate_answer, then draws a new one; reports unavailable with no approved questions; rejects an unknown token; enforces the view capability; stores the client’s pending events before drawing
generate_questions_test.php 2 Only what’s reachable without a real AI provider: denies a user without mod/playerpuzzle:managequestions, and rejects an empty topic before any AI call is attempted
get_phase_questionlog_test.php 5 Returns the attempt’s current-phase question log, scoped so a different phase’s answers never leak in; empty when nothing answered; rejects an unknown token; enforces the view capability
save_combat_state_test.php 14 A valid checkpoint persists and decodes back exactly, with a later one overwriting the earlier rather than accumulating; rejects a wrong-length board grid, an invalid turn value and an unknown token; enforces the view capability; the event log (moves and question-resolution events) is stored cumulatively, a resend only stores what is genuinely new (no duplication, no drops), a batch starting past the stored count is rejected (would leave a hole), a batch over the per-checkpoint size cap or the whole-phase event budget is rejected outright, and a move coordinate out of bounds is rejected
save_generated_questions_test.php 4 Denies a user without managequestions; saves a valid AI-reviewed batch as source=ai/approved=0, skips a malformed entry without failing the rest of the batch, and scopes saving strictly to the right instance
save_progress_test.php 29 Victory credits PuzzleCoin and the win-grant item (with/without PlayerHUD, and correctly reading max_single_matches in Single Match mode for the suppressxp check); a defeat never grants the win-grant item and discards any claimed gold; scoring is always against the phase/difficulty-scaled boss HP, never the client’s raw claim, whether the win/defeat is fully or only partially verified; rejects an unknown or replayed token; enforces the view capability; the minquestions backstop rejects a premature victory (never blocking a defeat) and allows one once met; an unverifiable victory restarts the phase (and counts as lost once restarts run out); a Demo victory never banks coins or updates the gradebook; the boss’s own coin gain nets against the player’s before payout; finishing an attempt clears its combat checkpoint and immediately recomputes completionwins; a full anti-cheat replay (known seed/log, including events carried only by the final call) derives the real banked value against the client’s inflated claim; a defeat never satisfies completionwins; banking happens under the same attempt+stock lock a Lobby purchase takes, and a busy lock leaves the attempt to retry cleanly
set_sound_preference_test.php 5 A valid call persists the preference for the calling user; Music/Sound Effects/Narration channels are saved independently, the narration channel defaulting to disabled; rejects an invalid channel; enforces the view capability
transfer_hud_coins_test.php 8 A transfer debits the PlayerHUD item and credits the same amount into PuzzleCoin, adding to (not overwriting) an existing balance; rejects an amount beyond the PlayerHUD balance, a non-positive amount, and a transfer when no PlayerHUD coin item is configured; isolated per instance/user; enforces the view capability; rejects the guest account
use_stock_test.php 15 A successful use debits 1 unit and records it; rejects using a type owned none of, and rejects once a type’s fixed per-phase limit (Shield: 1, Potion/Sword: 3) is reached; a hint use reveals text and debits hint stock, rejecting a question with no hint or one from another instance; a Demo attempt uses consumables for free without touching real stock, even for a type never owned, while still respecting the per-phase limit; rejects an invalid type and an unknown token; enforces the view capability; debiting happens under the same attempt+stock lock, and a busy lock changes nothing
validate_answer_test.php 19 Correct/wrong answers are reported accurately (wrong carries the real correct id for feedback); no open question and a question from another instance or unapproved are all rejected; enforces the view capability; rejects an unknown token and any client-supplied questionid parameter outright (the parameter was removed, not just ignored); a player’s answer is logged with the real chosen-answer text (never a forged answerid’s text from a different question); the boss’s server-drawn guess respects the configured precision per difficulty/qtype and consumes the open question without touching player question counters; a player answer increments questions_total/questions_correct and the response carries the server-counted total; every judged answer (player or boss) is stored as the server’s own outcome for the replay, including a vanished-but-open question stored as wrong, while no open question stores nothing at all
Subtotal 145  

Form Tests (tests/form/)

Test file Cases What is covered
import_form_test.php 3 The category select is populated with exactly the given categories, in order, through format_string(); renders with no options at all when there is nothing importable; the static notice and custom-labelled submit button are present
question_form_test.php 11 initial_repeat_count() never goes below 5 and grows to show every existing answer; a new question renders exactly 5 multichoice option slots, an edited one with more existing answers renders enough to show them all; the True/False radio hides for Multichoice and the Multichoice-only elements (option adder, correct-answer hint, mccorrect radio) hide for True/False; validation rejects an empty question text (but not one with only an embedded image), requires at least two filled Multichoice options, rejects a marked-correct option that is itself empty, and passes cleanly for a valid True/False or Multichoice submission
Subtotal 14  

Local Business-Logic Tests (tests/local/)

Test file Cases What is covered
ai_question_generator_test.php 19 No database or network needed: parse_and_validate() shapes a well-formed multichoice/truefalse entry, degrades to an empty result on malformed JSON or a missing "questions" wrapper, strips markdown code fences, drops a multichoice entry with the wrong correct-answer count or too few options, a truefalse entry missing "answer", an unknown qtype or an empty prompt, skips a non-array entry, respects the requested limit, and strips embedded HTML from untrusted AI text; validate_shaped_question() (the same safety gate applied to a teacher-resubmitted preview) accepts a valid multichoice question and rejects an unknown qtype, the wrong correct-answer count, too many options and blank answer text; build_prompt() includes the topic/count/language and both supported types
attempt_consumables_test.php 7 A type never used returns 0; recording a use creates then increments the row, scoped per attempt and never bleeding between attempts; reset_attempt() clears every type for that attempt only; get_uses_by_type() returns every type’s real count in one bulk read; each type’s fixed phase limit (or none, for Hint) is respected exactly at its own count
attempt_questions_test.php 1 Recording three answers across two phases and reading back only the asked phase’s rows, oldest first, in the template shape
coin_ledger_test.php 4 sync() ratchets the earned/boss-earned totals up to the credited value and never decreases an already-stored one; available() nets the boss’s share against the player’s, never negative; reset() clears both columns
combat_state_test.php 5 A board grid of exactly the right size is valid, a wrong-shaped one is not; encode()/decode() round-trip the grid and meters untouched; decode() returns null (not an error) for a missing snapshot or malformed JSON
game_page_service_test.php 27 check_attempt_limit(): 0 is always unlimited, Campaign counts against maxattempts and Single Match against max_single_matches independently, ignoring in-progress and Demo attempts; check_retry_cost(): the first attempt is always free, the configured quantity charges from the 2nd onward, blocks when insufficient, stays free with no item/no PlayerHUD block/while resuming/with only Demo history; build_game_config(): resolves phase-scaled HP (Single Match always uses base HP), applies difficulty to the boss only (kept across a resume, ignoring a later Lobby request), a Demo always fights at the fixed HP regardless of instance config, passes minquestions/questionstotal through (carried forward on resume) and the owned consumable stock/speech preference/rngseed+moveseq, a fresh attempt carries no checkpoint, a resumed real attempt is rebuilt through the server’s own replay rather than trusting the client’s last checkpoint (storing any event the rebuild had to add), while a resumed Demo simply keeps its own checkpoint; check_guest_demo_only() keeps the guest account to the Demo without affecting a real student
grade_calculator_test.php 21 No attempts at all yields no grade; Campaign: a fully won campaign grades 100%, a phase still being fought/lost never counts itself, losing the first phase grades 0, crossing a level boundary still credits the whole previous level, the grade reflects the furthest progress across every attempt (not just the latest), scales by the configured Maximum Grade (0 with no grade configured, never a division), and Considerar Erros blends progress with aggregated accuracy exactly when minquestions allows it (never dividing by zero); Single Match: each of the five grademethod formulas (highest/average/first/last/average-all, the last dividing by the configured max or falling back when Unlimited) is verified, a loss always scores 0 even with Considerar Erros on, a win is weighted by that match’s own accuracy, and an in-progress or nothing-finished attempt contributes no grade
hud_service_test.php 18 Block-instance lookup is null without a PlayerHUD block, correct once one exists, and ignores a block in a different course; is_installed()/is_outdated() reflect real class/definition presence; is_available_for_course() requires an actual block instance, not just the plugin being installed; get_items_for_block() returns only enabled items, sorted; get_item_name() resolves for the item’s own block instance and is empty for a foreign one; get_upgrade_level() reflects the same balance credit_coins() writes; credit_coins()/grant_item() succeed for a real quantity and are safe no-ops for an unconfigured item or a non-positive quantity, with grant_item() unaffected by the suppressxp flag
lobby_page_service_test.php 22 Base Lobby fields (Play URL/sesskey, the loadout shop) always appear regardless of PlayerHUD; the narration checkbox and PuzzleCoin balance/shop prices reflect the student’s own real state; the transfer widget appears only once a HUD coin item is configured; Campaign progress is shown only with a real in-progress attempt (never for Single Match, never for a lingering Demo attempt), correctly showing where the next attempt resumes after a loss (and no resume line after winning the whole campaign) and always offering “Play Demo”; the Minimum Questions notice appears only when configured; the difficulty picker offers three choices when nothing is in progress and shows a read-only current difficulty otherwise (falling back to Normal for a corrupted stored value); a mobile user agent adds mobile=1 to the Play URL; the ranking panel is built only when the teacher has it on; the guest account gets the Demo-only flag and no ranking
move_log_test.php 20 is_valid() accepts a normal, mixed, or empty batch of move/question events and exactly the size cap, rejects an unknown/missing event type, over-the-cap batches, a negative or out-of-bounds move coordinate, a missing coordinate, an invalid question side/outcome (including the old client-reported-correctness shape), and only accepts combat consumables (never Hint) for that event kind; merge() appends only events past the stored count (resend-safe, hole-safe); merging into an attempt tracks moveseq and refuses a result over the whole-phase budget; encode()/decode() round-trip a batch, encoding empty as null, and decode() treats broken input as an empty list
question_bank_sync_test.php 13 Imports a single-answer multichoice and a truefalse question with their answers/correctness, copies embedded files (without duplicating them on re-sync) and imports a question with more than 5 options in full; skips (and counts) a multi-answer multichoice question; re-syncing an unchanged category updates rather than duplicates, matched by sourceid, and genuinely propagates a source edit; a bank row whose source entry disappears is soft-disabled, never hard-deleted (an attempt’s answered-question log may still reference it), and — if approved and thus detached from sync, then the source reappears — is deliberately re-imported as a new row rather than re-linked; never touches manual questions; a usemine-without-useall user only imports their own authored questions while a useall user still imports everyone’s; rejects a category outside every reachable context; lists a category from a reachable one
question_editor_files_test.php 3 prepare() seeds a real draft area for a brand-new row and carries over an existing stored file into the draft area; finalize() moves a file back to the given real item id (leaving the @@PLUGINFILE@@ marker intact) and attaches a newly uploaded draft file to a real item id
question_list_service_test.php 3 An AI-generated question awaiting review is labelled pending with a working approve link; a bank-sourced question disabled by sync is labelled disabled with a “Reactivate” link instead; an approved question (any source) is labelled approved
questions_repository_test.php 21 add_question() creates a multichoice question with its answers in order, stores an empty hint as null, and supports an unapproved AI source; update_question()/update_question_content() each replace content while leaving provenance (source/sourceid/approved/addedby) untouched, on their respective manual-edit and bank-sync paths; set_approved()/approve_question() flip or approve-and-detach correctly (safely, for an AI question that never had a sourceid); delete_question()/delete_questions_bulk() remove the question, its answers and (given a context) their file areas, scoped strictly to owned ids; update_question() purges old answer file areas before replacing them; get_question() returns null for a missing or foreign-instance id, never leaking cross-instance; get_questions_for_instance()/for_management() scope, order, paginate and fall back to a safe sort column; get_draw_counts() counts only within the instance; approve_questions_bulk()/delete_questions_bulk() only ever touch ids the instance actually owns
ranking_service_test.php 9 Campaign ranks by how far a winning streak reached against a fixed 100-point base (independent of the instance’s own grade config), blends in accuracy when errors count, and ties break by fewer attempts; Single Match sums every finished match’s own score and ties break by fewer matches then less time; Demo matches and staff never appear; separate groups only show the viewer’s own group; the top rows are capped, with a lower-ranked viewer still getting their own row; nobody having played yet gives an empty ranking
replay_credit_test.php 8 An unverifiable victory restarts the phase (crediting nothing) while restarts remain, and counts as a defeat crediting only what the log still verifies once they run out; an unverifiable defeat is simply taken, never restarted; a Demo claim is kept unverified by design; a verified win matching the claim credits it silently, while one disagreeing with an inflated claim credits the derived value and fires a replay_diverged event; a verified defeat counts as lost regardless of the claim; restarting a phase genuinely resets its state (new seed, empty log, cleared coins/consumable uses) and counts one restart
report_service_test.php 17 The student report is empty with no enrolled students, still lists a student with zero attempts, and computes grade/average boss-damage correctly for a won Campaign, ignoring in-progress and Demo attempts and excluding report viewers themselves; SEPARATEGROUPS scopes both the student rows and the most-missed-questions statistics to the viewer’s own group; most-missed questions order by error rate; the score distribution buckets at both boundaries and ignores in-progress/Demo attempts; completion rate correctly counts a genuine win (Campaign) or any finished match (Single Match), ignores Demo, and is a clean zero (not a division error) with no students at all
sound_preferences_test.php 7 Music/Sound Effects default to enabled and Narration to disabled; set_enabled()/is_enabled() round-trip per channel, independently of each other, scoped per user, with a consistent preference-name prefix (the one db/uninstall.php matches against)
user_stock_test.php 10 A type never bought returns 0; credit() creates then adds, ignoring a non-positive quantity; debit() succeeds when enough is owned and refuses (unchanged) when not enough, never owned, or the quantity is non-positive; stock is isolated per user and instance; get_all() returns every type’s real quantity in one bulk read; delete_for_instance() removes only that instance’s rows
Subtotal 235  

Combat Engine Tests (tests/local/engine/)

Test file Cases What is covered
board_engine_test.php 25 Horizontal/vertical match detection for runs of exactly 3 and longer (never just 2), treating type 0 as real, deduping a shared intersection cell; is_match_at()/match_run_length_at() report correctly with and without a type filter; swap_in_grid() is its own inverse; evaluate_swap() reports the matched type and reverts the grid, or returns null with no match; find_move() finds an available swap or correctly reports none on an unwinnable board; pick_type_avoiding_match() never completes a run; generate_grid() replays a saved grid verbatim or rolls a fresh one with no initial match; apply_gravity_to_grid() drops floating pieces, leaves a full column untouched, and spawns using the injected rng; has_any_match() reflects the board; shuffle_grid() matches the documented Fisher-Yates result for a known sequence and only ever redistributes existing types; shuffle_until_valid() retries an invalid shuffle
combat_engine_test.php 29 combo_multiplier() is 1.0 at baseline, scaling linearly beyond; poison/shield meters arm at 100 (preserving overshoot) and are no-ops below it; phase_limit() returns the fixed per-type limit and INF for Hint; needs_revive()/revive_hp() (half of max, rounded up) and has_next_phase() (false outside Campaign, true mid-campaign, false on the very last phase); resolve_damage() applies full damage without a shield, blocks and consumes one when armed, and clamps at 0; resolve_poison_tick() deals one tick per round and clamps at 0; resolve_match_effects() covers every piece type (Potion heals the acting side only, Star raises its multiplier, Shield/Poison/Mana add to the acting side’s meter, Sword scales by combo group not per piece, Coin credits the acting side including the boss, Mana crossing 100 triggers a question for the right side and only then) without mutating the input state; resolve_consumable_effect() covers Potion/Shield/Magic/Sword’s own standalone effects
combat_test.php 9 calculate_boss_hp()/calculate_student_hp() match every value in the documented worked-example table; Level 1/Phase 1 always returns the base HP unchanged (the property every caller relies on); a zero base HP scales to zero; the difficulty factor table (with its fallback) and apply_difficulty() (a no-op on Normal); the boss guess-precision table per difficulty/qtype with its fallback; fixed consumable shop prices per type with a fallback; the fixed Demo HP constant
golden_vectors_test.php 5 generate_grid() matches a headless Node run of the real JS board engine bit-for-bit for two different seeds; shuffle_until_valid() and apply_gravity_to_grid() each match the JS side’s exact result for their own seeded scenario; a full seed-driven cascade (generate, find and apply the first move, then resolve destroy→gravity→re-check to settle) matches the JS side end to end
prng_test.php 5 The same seed always reproduces the same sequence, matching the JS side’s own documented sequence bit for bit; different seeds diverge; every draw lands in [0, 1); a seed outside the 32-bit range wraps the same way the JS side’s coercion does
question_fetcher_test.php 20 draw_random_question_id() is scoped to the instance, returns null with no approved questions, and never draws an unapproved one; get_single_question() never leaks correctness (Blind JSON), returns null for a foreign-instance or unapproved question, and flags whether a hint exists without ever leaking its text; is_answer_correct()/get_correct_answer_id() are scoped by question, never resolving cross-question, and handle a malformed row with no correct answer marked; get_question_type()/get_question_text()/get_answer_text()/get_answer_ids() resolve correctly and degrade to empty/null for an unknown or cross-question id; get_hint_text() is scoped to the instance and only returns text for an approved question that actually has one
replay_test.php 28 derive() returns null (never a false win) for a Demo attempt, an engine-version mismatch, an empty log, or a log not starting with a move; resolves a real single-move kill and a logged consumable applied before the move, correctly rounding match damage like the live client; returns null when a counted consumable is missing from the log or vice versa; resolves a real defeat; a used Hint alone never blocks verification; a missing question event, a malformed/impossible swap, a broken move event, or a pathological cascade all make the replay inconclusive rather than looping or crediting a false result; resolves a full multi-turn phase including an automatic boss turn and its own question; the boss revives mid-simulation instead of ending the match when minquestions is unmet; question outcomes always come from the server’s own judgement, never the client’s log, including when validation itself never came back; snapshotting an attempt’s current position from the log correctly resumes after the player’s move, the boss’s own unlogged turn, an open question (judged or not), or a consumable counted but never logged
security_test.php 43 generate_attempt_token() creates a genuinely unique, 64-character in-progress token, seeding the anti-cheat replay state (a real random seed, frozen boss/damage/coin config) fresh every time — never hardcoded or merged with stale values on a repeat seed; validate_and_consume_token() enforces the full anti-replay/isolation contract (rejects replay, wrong user, wrong instance, unknown token, and an invalid status as a coding_exception) while accepting every real final status; resume_or_create_attempt_token() creates a fresh attempt at Level 1/Phase 1 with no prior history, correctly flags/never-flags Demo (its own separate resume namespace, always starting fresh regardless of inherited position), preserves level/phase and rotates the token on a real resume without inserting a second row, isolates by instance/user and resolves to the most recent of several stale rows, keeps or applies difficulty appropriately, carries forward questionstotal and a decoded combat checkpoint (discarding one left with a dead boss), and resumes at the correct level/phase after a loss (clamped to a since-reduced maxlevels) while starting fresh after a full campaign win, correctly distinguishing “no prior attempt” from “already at 1/1”; has_inprogress_attempt() reflects a genuine row, ignoring Demo attempts and final statuses; with_locked_user_stock() runs its callback under a free lock
Subtotal 164  

Privacy Tests (tests/privacy/)

Test file Cases What is covered
provider_test.php 36 get_metadata() declares playerpuzzle_attempts and the three sound preferences, and — checked against the real schema via $DB->get_columns(), not a fixed key list — every real column of playerpuzzle_attempts, playerpuzzle_questions and playerpuzzle_user_stock is either declared or a documented, justified exclusion (anti-cheat replay plumbing, structural FKs), while playerpuzzle_question_answers carries no personal data at all; export_user_preferences() exports only a touched channel; contexts/users are discovered via attempts, loadout stock alone, or question authorship alone; export_user_data() carries attempt data including the move log and question outcomes (unlike their anti-cheat sibling fields), across multiple contexts, and is a no-op for a non-module context; deletion (per-user, per-users, and whole-context) removes only the intended rows/contexts, is a no-op for non-module contexts or a missing course module, and — for question authorship specifically — anonymizes addedby rather than deleting the question itself, on all three deletion paths; a regression guard confirms every query joins through {modules}.name = 'playerpuzzle' so a colliding instance id from a different module type is never mistaken for this one’s data
Subtotal 36  
Grand Total 669
vendor/bin/phpunit --testsuite mod_playerpuzzle_testsuite

Coverage

Measured locally with Xdebug (moodle-coverage, a bench tool — not part of CI):

  Coverage
Classes 44.68% (21/47 fully covered)
Methods 74.03% (191/258)
Lines 86.79% (3817/4398)

(Snapshot: 2026-09-24, 669 tests.)

Line coverage is the more informative number here: most classes below 100% method coverage still sit at 90%+ lines — the uncovered methods tend to be edge-case branches (an already-empty result set, a guard clause every test already satisfies) or a Moodle event class’s own boilerplate (get_url(), init()) that a normal trigger-and-assert test never calls, not untested features. The classes actually worth a closer look, with real line coverage below 65%:

JavaScript — Unit Tests (tests/js/engine/)

amd/src/engine/board_rules.js, combat_rules.js and prng.js hold the deterministic match-3/combat/PRNG math the client must reproduce bit-for-bit against the server’s own PHP engine (classes/local/engine/) for the anti-cheat replay to ever agree with what was really played. That math is extracted into small UMD modules and tested headless via Node’s own built-in test runner — no Jest, no browser, no Moodle/RequireJS/Phaser runtime at all. Each module’s UMD wrapper falls back to a plain CommonJS module.exports outside a define() environment, so a test require()s it directly. This is the first plugin in the ecosystem with a JS test layer of its own, so there is no house convention to follow yet — see the CI workflow for exactly how it plugs into the pipeline (its own step, gated to the runchecks: all leg alongside Grunt, since it is toolchain-level rather than Moodle-version-dependent).

Test file Cases What is covered
board_rules.test.js 25 JS mirror of board_engine_test.php: horizontal/vertical match detection for runs of exactly 3 and longer (never just 2), treating type 0 as real, deduping a shared intersection cell; isMatchAt/matchRunLengthAt with and without a type filter; swapInGrid is its own inverse; evaluateSwap reports the matched type and reverts the grid, or returns null; findMove finds an available swap or reports none on an unwinnable board; pickTypeAvoidingMatch never completes a run; generateGrid replays a saved grid verbatim or rolls a fresh one with no initial match; applyGravityToGrid drops floating pieces, leaves a full column untouched, and spawns using the injected rng, never Math.random directly; hasAnyMatch reflects the board; shuffleGrid matches the documented Fisher-Yates result for a known sequence and only redistributes existing types; shuffleUntilValid retries an invalid shuffle
combat_rules.test.js 29 JS mirror of combat_engine_test.php: comboMultiplier scaling, poison/shield meter arming (with overshoot handling), phaseLimit/needsRevive/reviveHp/hasNextPhase, resolveDamage (shield block, clamp at 0), resolvePoisonTick, every resolveMatchEffects piece type (Potion, Star, Shield/Poison/Mana, Sword scaling by combo group, Coin, Mana-crossing question triggers for the right side only) without mutating input state, and resolveConsumableEffect’s own standalone Potion/Shield/Magic/Sword effects
prng.test.js 5 JS mirror of prng_test.php: the same seed always reproduces the same sequence, matching a documented exact sequence bit for bit; different seeds diverge; every draw lands in [0, 1); a seed outside the 32-bit range wraps the same way >>> 0 coercion would
Subtotal 59  

These three files are a deliberate bit-for-bit mirror of their PHP counterparts, not an independent test suite: tests/local/engine/golden_vectors_test.php on the PHP side locks in fixtures produced by running these exact JS modules headless through Node, so a future change that desyncs either side’s rng-draw order, iteration order, or retry condition fails on the PHP side even before a live client/server mismatch would ever surface.

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

Behat — Acceptance Tests

Feature file Scenarios What is covered
mod_playerpuzzle_accessibility.feature 4 The accessible board (a real <table role="grid"> mirroring the Canvas — see Accessibility) announces whose turn it is; grabs keyboard focus on its own, with no need to Tab to it; Space re-reads the available moves without acting; a digit key executes a move without erroring
mod_playerpuzzle_gameplay.feature 4 Scoped deliberately to the Moodle frame around the pre-match loadout shop only — never board/combat mechanics, which run entirely in the Canvas with no deterministic seed to drive a real match against without a flaky retry-until-it-matches scenario: the Lobby shows the PuzzleCoin balance and shop; buying a consumable debits/credits via AJAX without reloading the page; buying without enough PuzzleCoin shows an error instead of failing silently; a loadout purchase does not block entering the game
mod_playerpuzzle_match.feature 3 Every attempt seeded with PRNG seed 2, so the accessible board’s announced moves are fixed and deterministic (move 4, a Sword match, always matches the boss’s configured HP): a winning move ends in a verified victory that banks its coins; a move leaving the student to the boss’s turn ends in a verified defeat; a victory the server’s own replay cannot verify restarts the phase instead of counting
mod_playerpuzzle_ranking.feature 3 A one-level campaign’s ranking math (phase N credits (N-1)×10 of 100 points): the ranking panel opens in place of the Lobby’s own panel and closes back to it; a teacher can turn the ranking off; with separate groups a student only sees their own group’s ranking
mod_playerpuzzle_settings.feature 1 A teacher adds a PlayerPuzzle activity and it appears on the course page
mod_playerpuzzle_smoke.feature 1 A student opens the Lobby and can start the game — the baseline Moodle-chrome flow (adding the activity, reaching the Lobby, entering a match), deliberately never touching Canvas pixels themselves
Subtotal 16  
php admin/tool/behat/cli/init.php
vendor/bin/behat --tags=@mod_playerpuzzle --profile=chrome