🧪 Automated Tests
PlayerWords ships with a PHPUnit test suite covering business logic, repository queries, web services, and Privacy API compliance, plus a Behat suite covering gameplay, PlayerHUD integration, and reports end-to-end in a real browser. Every CI push runs against the full matrix (Moodle 4.5 → 5.x, PostgreSQL & MariaDB).
PHPUnit — Core Tests
| Test file | Cases | What is covered |
|---|---|---|
backup_restore_test.php |
6 | Duplicating an activity copies its words, renames the copy, rebuilds the course cache, and does not create a duplicate grade item — regression guard for a missing prepare_activity_structure() call; a PlayerHUD item reference survives a same-course “Duplicate activity” unchanged (the block is never part of that narrower backup); a full course backup/restore into a new course remaps the reference to the new item’s id, via the playerhud_item restore mapping block_playerhud’s own restore step registers; a reference to another course’s item is dropped rather than kept pointing at the wrong course, against the real backup_controller/restore_controller, not a hand-rolled shortcut; a full course backup/restore preserves the grade/ranking scoring mode settings and both score and rankingpoints on a finished attempt |
cross_instance_security_test.php |
4 | Session state, word lookups by id, attempt records, and the “my attempts” history query never leak between two different activity instances, even for the same student in the same course |
lib_grant_potential_test.php |
6 | The playerhud_grant_potential callback discovered by PlayerHUD’s own “Total XP in the game” ceiling estimate: empty for an unrecognised block instance, for an activity with no win-grant item configured, and for an unlimited activity (mirrors the anti-farming rule on the real grant); a bounded activity returns one row shaped like PlayerHUD’s own item/quest breakdown entries (qty × max_rounds × item xp); a win-grant item belonging to a different course’s block instance contributes nothing; two bounded activities in the same course each contribute their own row |
lib_reset_userdata_test.php |
4 | Course reset deletes attempts and resets grades only when the checkbox is enabled, only for the target course, and the form default enables it |
lib_supports_test.php |
2 | Known feature flags return their documented value and an unrecognised one returns null; FEATURE_MOD_OTHERPURPOSE (only defined from Moodle 5.1 onwards) is checked when the constant actually exists on the branch running the test |
completion/custom_completion_test.php |
7 | Custom completion rule (“require attempts”): incomplete below threshold, complete at threshold, rule not reported as available when disabled, defined rule names, rule description includes the required count, display sort order, a still-pending reservation (round started but not finished) is not counted towards the threshold |
privacy/provider_test.php |
30 | Metadata declaration (including the site-wide “seen intro” user preference) and, for playerwords_attempts, an exact field-set match against the real table schema; contexts by attempts; contexts by words added, both ignoring a course_modules instance-id collision with another module type; list users in context (no-op for a non-module context, and for the same collision); export user data (no-op for an empty contextlist, for a non-module context, and for a context whose instance id cannot be resolved); export_user_preferences reports nothing for a user who never triggered the intro-seen preference, and exports it correctly (component, name, description) once they have; delete user data across a single and across multiple contexts, no-op for an empty contextlist, a non-module context, and an unresolvable instance; delete all users’ data in a context (leaving another activity untouched; no-op for a non-module context and for an unresolvable instance); delete data for a list of users (no-op for a non-module context, an unresolvable instance, and an empty user list); export_user_data across multiple contexts returns correctly segregated per-context data, not blended across activities; the DB read count stays bounded instead of scaling linearly with the number of approved contexts, regression-guarding the batched attempts/words lookup |
lib_update_grades_test.php |
2 | A student’s stale grade is genuinely cleared (not just left untouched) once their last attempt is removed, so grade_item::has_grades() correctly returns false again — the fix that lets the attempts-report delete feature actually unfreeze mod_form’s attempt-gated settings; a student with other attempts remaining after one is deleted gets their grade recomputed from what is left |
mod_form_test.php |
3 | The Ranking scoring mode lock reacts to real attempt data, not just the grade item: freezes the moment any finished attempt exists even on a fully ungraded activity, stays unfrozen for a brand-new activity with no attempts at all, and is unaffected by a still-open (pending) reservation |
lib_grade_item_update_test.php |
2 | A configured pass grade is actually applied to the gradebook item instead of being silently dropped by core’s grade_update(); passing 'reset' clears every grade recorded against the item without deleting the item itself |
| Subtotal | 66 |
Local Business-Logic Tests (tests/local/)
| Test file | Cases | What is covered |
|---|---|---|
ai_word_generator_test.php |
17 | AI response parsing (words/legacy concepts wrappers, bare list, markdown code fence stripped, malformed/non-array JSON, hint falls back to definition, non-array entries skipped) and untrusted-input term validation (single alphabetic word accepted; empty, multi-word, and non-alphabetic terms rejected; a term longer than the word column is rejected, one exactly at the limit is still accepted) — all via reflection, no real AI call; the generation prompt lists existing pool words to avoid when any are given, and omits that section entirely when the pool is empty |
attempts_history_service_test.php |
23 | Own attempt history and current grade: empty with no finished rounds; excludes a still-pending reservation; rows shown most-recent-first while the grade calculation itself uses ascending order; the computed grade matches playerwords_calculate_user_grade() for the configured method; grade summary hidden for an ungraded activity; word text falls back from concept to the raw word; time used formatted as m:ss; the ranking-points column is shown, formatted to 2 decimals, when ranking is enabled, and omitted entirely when it is disabled. All-students report (get_all_history): every student’s finished attempts included with their name attached, most-recent-first by default; excludes anyone who can manage the activity, from both the report rows and the student filter dropdown; the studentid filter restricts to one student’s own rows; sorting only accepts allow-listed columns, falling back to date instead of erroring on an unknown key; pagination returns distinct slices of the full result set; the report and the student-filter dropdown both honour $CFG->fullnamedisplay and moodle/site:viewfullnames, hiding the surname for a viewer without the capability; delete_attempts() removes the row and fires attempt_deleted with the right objectid/relateduserid/other data, is a no-op for an attemptid belonging to another instance, is blocked by SEPARATEGROUPS from deleting a student outside the viewer’s own group, and a bulk call across two students returns their affected userids deduplicated |
gameplay_service_test.php |
20 | Letter feedback algorithm across 9 guess/target combinations (correct, absent, present, duplicate letters, pool exhaustion); score calculation for win, loss, and decimal grades under Binary mode; Linear mode for both the grade and the ranking-points calculation (full marks on both of the first two attempts, scaled proportionally from the third attempt onward, a positive non-zero share on the last allowed attempt, degenerates to full credit on every attempt when max_attempts is 2 or fewer, zero when not completed) |
hud_service_test.php |
28 | Delegates to block_playerhud’s \block_playerhud\local\external_items API for every item operation, validating ownership against the caller’s own block instance instead of reading block_playerhud’s tables directly: block lookup across courses; whether the block_playerhud plugin itself is installed on the site (matches the real class_exists check); course availability (true with a block instance, false without one, ignores another course’s instance); item name resolution (empty for an item belonging to a different block instance); item list retrieval; consume items (insufficient funds, success, FIFO order, zero-quantity short-circuit, waived — not blocked — for an item belonging to a different block instance); grant items (inventory rows tagged source='playerwords' plus XP awarded, XP withheld when the caller flags the source as unbounded, zero-XP items never change XP either way, unknown item, foreign-instance item, and zero quantity are all no-ops); all four item-cost/grant methods return their documented neutral values instead of fataling when block_playerhud is not installed |
intro_service_test.php |
5 | The site-wide “seen intro” user preference: false by default; flips true and stays true after marking it seen (idempotent); isolated per user — marking one user never affects another; the preference name is prefixed with the plugin’s Frankenstyle component, the contract both the Privacy Provider and db/uninstall.php’s prefix-based cleanup rely on |
ranking_service_test.php |
9 | Empty ranking; score-descending ordering; top-5 truncation with an outsider row for a lower-ranked current user; SEPARATEGROUPS filters to the student’s own group; a still-pending reservation (round in progress or abandoned without finishing) is excluded from the ranking; a user who can manage the activity (editingteacher) never appears in the ranking, even with attempts of their own; the displayed name honours $CFG->fullnamedisplay, hiding the surname for a viewer without moodle/site:viewfullnames and showing it in full for one who holds the capability |
round_presenter_test.php |
46 | Grid row rendering; cooldown text; feedback messages (forfeited/timed out/lost/won, varying by attempts used); ranking context; round result context (blank until finished, reveals on finish, cooldown reflects a later settings change); lobby PlayerHUD balance/cost (shown/hidden by round state, start disabled below the required quantity, enabled once the balance covers it), lobby timer info; round panel hint-button PlayerHUD balance/cost (shown/hidden by reveal state, hint disabled below the required quantity, enabled once the balance covers it), the hint button is hidden entirely when hints are disabled for the activity, even for a word that has a hint configured, timer stays at zero before the round starts; grade-so-far summary (absent before finished, absent when ungraded, shows method and computed grade once finished, ignores a still-pending attempt); lobby grading-method info line (shown when relevant, hidden for a single-round activity, hidden when ungraded); the keyboard’s Ç key only appears once the activity’s own word pool needs it; rounds-played counter shown in the lobby and in the round result, using the infinity symbol for an unlimited activity and the configured limit otherwise; the PlayerHUD win-grant label is shown only on an actual win with an item configured, blank on a loss or when unconfigured; ranking_scoring_explanation() embeds the matching Binary/Linear detail string for whichever mode the activity is configured with |
round_service_test.php |
61 | Round state transitions: word picked and round_started fired, and a finished round whose backing attempt row a teacher has since deleted from the attempts report resets to a fresh, unfinished state instead of leaving a stale result on screen forever, while an in-progress round’s state survives untouched; guess submission (wrong, correct, out of attempts, after finish, length mismatch, empty target word, non-alphabetic characters, finishes once time runs out mid-guess even inside round_expired()’s own tolerance window); forfeit and timeout, each rejected both for a word armed but not started and for no active word at all; new round (resets state, and is a safe no-op with no prior session); restriction notice (max rounds reached, active cooldown, unrestricted); count_rounds_played scoped to instance and user; cooldown computation (disabled, no attempts yet, expired, reflects a later settings change); ensure_round_state returns immediately once already finished, recovers by picking a fresh word after the previous one was removed mid-round, and backfills the roundstarted flag for a session that predates it; start_round reserves an attempt row; finish_round completes the reservation instead of duplicating it, inserts a fresh record for a legacy session with no reservation to complete, and updates automatic completion state on a genuine win; an abandoned round still counts towards max_rounds; the stale reservation is discarded when the word is removed mid-round; a won round grants the configured PlayerHUD item with XP when max_rounds is bounded, grants it without XP when unlimited, and a lost round never grants it; reveal_hint is rejected once finished and when not yet started, and a hint cost pointing at a merely disabled item still blocks it when the balance is short; a round or hint cost pointing at a deleted item, or an item belonging to a different course, is waived rather than blocking the student forever; a round cost pointing at a merely disabled (not deleted) item still blocks correctly when the balance is short — disabling is reversible, so the cost is never waived for it |
view_page_service_test.php |
22 | Page-assembly branches: fresh lobby, picked word persists across calls, finished round computes a real cooldown, restriction notice shown when the round limit is reached; the toolbar’s help/attempt-history URLs are always present; the forfeit action is shown only during an active round; the help modal’s ranking tie-break explanation is hidden when the teacher has turned ranking off; the help modal’s PlayerHUD explanation appears as soon as any one of round cost, hint cost, or win grant is configured; the help modal always carries the review hint pointing back to the toolbar icon. Auto-show intro: true on a user’s very first page load of any activity and false from then on — including a different activity, proving the scope is site-wide rather than per-instance — surfaced identically on the fresh-lobby, finished-round, and restriction-notice branches. Word-pool status: whoever can manage the activity sees the active-word count even with no issues at all, and, only when the pool genuinely has one, a named inactive-word warning (outside the length range or containing an invalid character); a student sees neither; the restriction-notice branch reports the round as finished so the header timer badge stays hidden instead of showing a stray “0” |
word_normalizer_test.php |
16 | Accent-insensitive normalisation across 8 diacritic combinations; is_valid_charset accepts letters only (including accented ones) and rejects digits, spaces, hyphens, an apostrophe, and an empty string, across 8 cases |
words_repository_test.php |
65 | Word picking (empty pool, unapproved/too-short/too-long/non-letter exclusion, random mode, shared-sequence determinism and cycling, avoids the excluded word when an alternative exists, allows it back in when it is the only candidate); get_last_played_word_id (0 with no finished rounds, ignores a pending reservation); manual and AI word insertion, the latter skipping a word already present in the pool; word_exists duplicate check (case-insensitive match, no match, scoped to instance, matches regardless of source, ignores the excluded word id when renaming); word lookup, update and delete scoped to the owning instance; bulk delete and approve; recent-words listing, paginated, with glossary name join; glossary sync (multi-word concept splitting, configurable stopword filtering, hint update on resync without duplicating, orphan cleanup when an entry disappears, glossaryid = 0 covering every course glossary, skips a concept whose text already belongs to a manual/AI word without touching that word’s hint, skips a token longer than the word column instead of aborting the whole sync, and — with glossary_split_concepts off — a multi-word concept skipped entirely instead of split, with a sibling single-word concept unaffected, and that this is the instance default when the setting is never explicitly set); the on-screen keyboard’s Ç key is only offered when an approved word actually contains one, scoped to its own activity and ignoring unapproved words. get_fragmented_concepts reports a glossary concept split into several sibling word rows, excludes a single-word concept, ignores manual/AI words even when one coincidentally shares its text with its own concept, and is scoped to its own instance. get_inactive_words is empty when nothing is wrong, reports a word outside the current length range and a word with an invalid character (each tagged with its reason), and ignores unapproved words. get_draw_counts is absent — not zero — for a word never drawn, sums every attempt regardless of outcome, and is scoped to its own instance. count_glossary_candidates (the same preview used before an activity even exists) counts only candidates within the requested range for one glossary; glossaryid = 0 covers every glossary in the course; two concepts tokenising into the same word are only counted once; a glossary id belonging to a different course is never counted, even though the id itself is real; a course with no glossaries at all counts zero without error; zero for a multi-word concept when splitconcepts is passed false; get_recent_words falls back to the safe default sort (id/DESC) when given an out-of-allow-list column or direction, instead of trusting the caller |
| Subtotal | 312 |
Web Services Tests (tests/external/)
| Test file | Cases | What is covered |
|---|---|---|
count_eligible_words_test.php |
6 | Counts only approved pool words whose length falls within the requested range; excludes unapproved words and words outside the range; scoped to its own activity instance; requires the mod/playerwords:addinstance capability (rejects a student) |
count_glossary_candidates_test.php |
5 | Counts candidate words for a specific glossary within the requested length range; a word outside the range is excluded; a stopword passed straight from the settings form (not yet saved to any instance) drops the matching token before counting; the splitconcepts param, passed straight from the settings form’s own checkbox state, zeroes out a multi-word concept’s contribution when false; requires the mod/playerwords:addinstance capability (rejects a student) |
end_round_test.php |
6 | Forfeit finishes the round; timeout finishes the round; an invalid reason value is rejected; the mod/playerwords:view capability is required |
new_round_test.php |
5 | A new round picks a fresh word; blocked when the round limit was already reached; the mod/playerwords:view capability is required; blocked while a round is genuinely in progress, closing the gap where a client could discard a losing round before it was recorded; blocked while a word is merely armed in the lobby (not yet started), closing a free re-roll gap for word length/difficulty |
reveal_hint_test.php |
8 | Hint is revealed; revealing twice is idempotent; rejected once the round is finished; rejected for the whole activity when hints are disabled, even though the picked word has one configured; the mod/playerwords:view capability is required; an insufficient PlayerHUD item balance (a real, valid item) blocks the reveal; a cost pointing at a deleted item is waived instead |
start_round_test.php |
7 | Round timer starts; rejected when already started; the mod/playerwords:view capability is required; an insufficient PlayerHUD item balance (a real, valid item) blocks starting; a cost pointing at a deleted item is waived instead |
submit_guess_test.php |
10 | A wrong guess never reveals the word; a correct guess reveals it only once finished; a losing guess also reveals it; the mod/playerwords:view capability is required; timeleft reflects seconds remaining while in progress; timeleft is frozen at the moment the round finished, not the wall clock; a fractional ranking total survives the external API’s return-value cleaning, against the real webservice call |
| Subtotal | 47 |
| Grand Total | 425 |
vendor/bin/phpunit --testsuite mod_playerwords
Line coverage by class (PHPUnit + Xdebug):
| Class | Line coverage |
|---|---|
completion\custom_completion |
100% |
event\attempt_deleted |
46% |
external\count_eligible_words |
100% |
external\count_glossary_candidates |
100% |
external\end_round |
100% |
external\new_round |
100% |
external\reveal_hint |
98% |
external\start_round |
100% |
external\submit_guess |
100% |
local\ai_word_generator |
36% |
local\attempts_history_service |
98% |
local\gameplay_service |
95% |
local\hud_service |
91% |
local\intro_service |
100% |
local\ranking_service |
98% |
local\round_presenter |
98% |
local\round_service |
99% |
local\view_page_service |
97% |
local\word_normalizer |
100% |
local\words_repository |
99% |
privacy\provider |
100% |
| Overall | 90% |
Most of the other event/*.php classes aren’t listed — Moodle only loads them lazily when the
corresponding event actually fires, so the instrumentation never sees them. attempt_deleted
is the one exception, since attempts_history_service_test.php genuinely constructs and fires
it: its 46% covers init() and validate_data() (exercised by every create()/trigger()
call), while get_name(), get_description() and get_objectid_mapping() stay uncovered —
they only run when actually rendering a log entry or mapping a restore, neither of which a
plain “the event carries the right objectid/relateduserid” assertion exercises.
hud_service carries a deliberate gap: get_available_quantity(), get_item_name(),
consume_items() and grant_items() each carry an if (!self::is_installed()) guard clause
returning a neutral value — the fallback path for a site where block_playerhud was never
installed or has since been removed. Every dev and CI environment for this plugin installs
block_playerhud alongside it, so those four branches are only exercised by
test_item_methods_return_neutral_values_when_not_installed() on a site that genuinely lacks
the block; here it is skipped instead. ai_word_generator’s own external AI-calling methods
(call_ai(), call_core_ai(), generate_and_save()…) are the other deliberate gap: covering
them would mean mocking the actual local_aihub/core_ai HTTP calls, judged not worth it for
now.
Behat — End-to-End Tests
PlayerWords also ships a Behat suite that drives the game in a real browser session, covering gameplay, PlayerHUD integration, teacher-facing reports, and the toolbar/modals — areas a PHPUnit unit test cannot exercise (JavaScript-driven UI, real page navigation).
| Feature file | Scenarios | What is covered |
|---|---|---|
mod_playerwords_smoke.feature |
1 | The lobby loads and a round can be started — the baseline sanity check the rest of the suite builds on |
mod_playerwords_gameplay.feature |
7 | Winning a round on the first try hides the timer badge; losing a round after exhausting all attempts; arrow keys move focus between a guess row’s own boxes without changing any value; forfeiting an active round asks for confirmation; a round ends automatically once its timer runs out; reaching the round limit hides the new-round action instead of a dead end; a configured cooldown shows a countdown instead of the new-round button |
mod_playerwords_playerhud.feature |
4 | The lobby blocks starting a round until the student can afford the configured item cost; revealing the hint asks for confirmation and enough balance; a round starts and the hint reveals for free once the configured item no longer exists; winning a round grants the configured PlayerHUD item |
mod_playerwords_reports.feature |
5 | A student sees only their own attempt history, never another student’s; the teacher’s all-students report paginates past 30 rows, sorts by clicking a column header, and filters to a single student; the ranking page shows the top 5 plus the current user’s own row when they fall outside it |
mod_playerwords_settings.feature |
4 | The scoring mode and attempt-count settings freeze once a real grade exists; adding a manual word already in the pool, or containing a character the game cannot use, is rejected; a PlayerHUD item that no longer exists stays selected in the settings form instead of silently resetting |
mod_playerwords_toolbar.feature |
9 | The manage-words icon and the inactive-words warning only appear for whoever can manage the activity; the ranking icon only appears when ranking is enabled; the forfeit icon only appears while a round is active; the hint button only appears when hints are enabled for the activity; the help modal shows its optional paragraphs only when relevant, and hides them otherwise; the how-to-play modal opens automatically on a player’s very first visit, once ever; cancelling the forfeit confirmation leaves the round untouched |
| Subtotal | 30 |
vendor/bin/behat --config public/behat.yml --profile=chrome --tags @mod_playerwords