diff options
| author | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
| commit | a726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch) | |
| tree | 4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /web/tests | |
| parent | 8c95e90fa54db565919ec323818f77e7256812c8 (diff) | |
| download | typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip | |
Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer
- Quick match: POST /api/multiplayer/quickmatch returns whichever room is
still filling, or opens one. Players never see a room code; joining by
code stays for racing specific people.
- Bots fill quick-match rooms after a short wait so a new game is never an
empty lobby. They only ever join quick-match rooms, never a room opened
by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so
two bots are never near each other's pace, and they stall to correct
mistakes rather than typing a clean straight line.
- Live player count via GET /api/multiplayer/online, shown on the
Multiplayer control and under the main menu's Multiplayer button.
- Per-racer colours: you are the theme accent, opponents take distinct hues
that stay the same from lobby to race.
- The countdown no longer holds the room lock for its full three seconds,
which is what reset clients mid-countdown.
Typing languages
- 16 languages for the generated-word modes, each with its own
high-frequency vocabulary rather than a translation of the English list.
- Picker in the top-right rail; non-English uses its own list at every
difficulty tier instead of falling back to English words.
Fix UTF-8 accuracy in the game core
- update_game_state mixed byte and character counts: total_characters_typed
accumulated byte-length deltas while total_correct_characters compared a
char index against that byte count. Equal on ASCII, so it went unnoticed;
a correctly typed Spanish passage scored 6%. The old byte slicing would
also have panicked if an index landed inside a multi-byte character.
Rewritten char-based, with regression tests.
Programming mode
- Replaced prose about programming with real code: 26 syntax-highlighted
snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line
by necessity, since the typing input is a single-line field.
Layout and readability
- One icon rail arrangement on every screen: Settings/Store under the
wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/
Multiplayer bottom-right.
- Main menu: mode picker moved out of the Single Player button, which it was
notching a divider through and pushing the label off-centre.
- Escape returns to the menu, closing any open popover first, and confirms
before abandoning a live race.
- Split --text-color and --sub-color per theme; they shared one value that
measured 3.65:1 against the background, below the 4.5:1 body-text floor.
- Semantic colours used in exactly one place each: gold for a personal best,
amber for the race countdown and the mobile-result badge.
- Passage now sits in the same place on the typing and end screens, and its
column is a whole number of characters wide so wrapping cannot leave a
permanent gap on the right.
- End screen: keystrokes and a correct/wrong/extra/missed split, attribution
carried over from the typing screen, and a graph with a separate error
axis, axis titles including seconds, and smoothed lines.
Diffstat (limited to 'web/tests')
| -rw-r--r-- | web/tests/README.md | 42 | ||||
| -rw-r--r-- | web/tests/helpers.py | 38 | ||||
| -rw-r--r-- | web/tests/run_all.py | 42 | ||||
| -rw-r--r-- | web/tests/test_account_and_store.py | 56 | ||||
| -rw-r--r-- | web/tests/test_leaderboard_and_friends.py | 60 | ||||
| -rw-r--r-- | web/tests/test_typing_flow.py | 54 |
6 files changed, 292 insertions, 0 deletions
diff --git a/web/tests/README.md b/web/tests/README.md new file mode 100644 index 0000000..fadc5b9 --- /dev/null +++ b/web/tests/README.md @@ -0,0 +1,42 @@ +# TyperPunk web test suite + +Playwright end-to-end tests covering the major user-facing flows: typing a +test through to the end screen, account/store/cosmetics, and +leaderboard/friends. These replace the ad-hoc scripts written into `/tmp` +during development - checked in here so they survive past one session and +can be re-run for regressions. + +## Prerequisites + +- Python 3 with the `playwright` package installed (`pip install + playwright && playwright install chromium`, or on Arch, + `pacman -S python-playwright`). +- The web app's dev server running on port 4173: + `cd web && node serve.mjs` +- The backend running on port 8787, pointed at a throwaway database (do not + run tests against a production database): + `DATABASE_URL="sqlite:///tmp/typerpunk_test.db" PORT=8787 COOKIE_SECURE=0 \ + cargo run --package typerpunk-server` + +Both servers must already be running before starting the suite - it does +not launch or manage them itself. + +## Running + +``` +cd web/tests +python3 run_all.py +``` + +Each `test_*.py` file exposes a single `run()` function that raises +`AssertionError` on failure. `run_all.py` imports and calls each one in +turn, continuing after a failure so one broken test doesn't hide the +others, and exits non-zero if anything failed. + +To run a single file directly: `python3 test_typing_flow.py`. + +## Adding a test + +Add a new `test_*.py` file with a `run()` function, using Playwright's sync +API (see the existing files for the pattern). `run_all.py` discovers files +by the `test_*.py` name, so no registration step is needed. diff --git a/web/tests/helpers.py b/web/tests/helpers.py new file mode 100644 index 0000000..8aca3f6 --- /dev/null +++ b/web/tests/helpers.py @@ -0,0 +1,38 @@ +"""Shared helpers for the Playwright test suite. See README.md for how to +run these tests and what needs to already be running. +""" +import random +import string + +FRONTEND_URL = "http://localhost:4173" + + +def random_username(prefix="test"): + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + return f"{prefix}_{suffix}" + + +def random_password(): + """A fresh password per run. These accounts are throwaway, so nothing is + gained by a fixed literal - and a hardcoded one in a checked-in file has + the shape of a real credential, which trips secret scanners.""" + return "Tp" + "".join(random.choices(string.ascii_letters + string.digits, k=16)) + + +def register_and_login(page, username, password=None): + """Registers a fresh account through the real Account screen and leaves + the app on the main menu, signed in.""" + password = password or random_password() + page.goto(FRONTEND_URL, timeout=40000) + # The account entry point is the top-right identity control: "Sign In" + # when signed out, your username once signed in. It used to be an icon in + # the main menu's bottom-left cluster. + page.click('.auth-control button') + page.wait_for_timeout(300) + page.click('[data-action="tab-register"]') + page.fill('input[name="username"]', username) + page.fill('input[name="password"]', password) + page.click('.account-form button[type="submit"]') + page.wait_for_selector('.account-signed-in', timeout=10000) + page.click('[data-action="menu"]') + page.wait_for_timeout(300) diff --git a/web/tests/run_all.py b/web/tests/run_all.py new file mode 100644 index 0000000..91064da --- /dev/null +++ b/web/tests/run_all.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Runs every test_*.py file in this directory and reports pass/fail. See +README.md for prerequisites (both servers must already be running). +""" +import glob +import importlib +import os +import sys +import traceback + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +def main(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + files = sorted(glob.glob(os.path.join(test_dir, "test_*.py"))) + if not files: + print("No test_*.py files found.") + return 1 + + failures = [] + for path in files: + name = os.path.splitext(os.path.basename(path))[0] + module = importlib.import_module(name) + try: + module.run() + except Exception: + failures.append(name) + print(f"{name}: FAIL") + traceback.print_exc() + + total = len(files) + passed = total - len(failures) + print(f"\n{passed}/{total} passed") + if failures: + print("Failed: " + ", ".join(failures)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/tests/test_account_and_store.py b/web/tests/test_account_and_store.py new file mode 100644 index 0000000..ce1b9c9 --- /dev/null +++ b/web/tests/test_account_and_store.py @@ -0,0 +1,56 @@ +"""Account registration, and the cosmetics store's buy/equip flow -- +including that an equipped caret color and flair icon actually show up, +not just that the API calls succeed. See README.md to run. +""" +from playwright.sync_api import sync_playwright +from helpers import FRONTEND_URL, random_username, register_and_login + + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch() + try: + page = browser.new_page(viewport={"width": 1280, "height": 900}) + username = random_username("store") + register_and_login(page, username) + assert page.locator('.auth-control button').count() > 0 + + page.click('[data-action="store"]') + page.wait_for_selector(".store-item-row", timeout=10000) + rows = page.locator(".store-item-row") + assert rows.count() > 0, "store catalog did not render" + + def buy_and_equip(item_name): + row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name)) + buy_btn = row.locator('[data-action="buy"]') + if buy_btn.count() > 0: + buy_btn.click() + page.wait_for_timeout(500) + row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name)) + row.locator('[data-action="equip"]').click(timeout=10000) + page.wait_for_timeout(500) + + buy_and_equip("Cyan Caret") + buy_and_equip("Bolt") + + equipped_rows = page.locator(".store-item-row", has=page.get_by_text("EQUIPPED")) + assert equipped_rows.count() >= 2, "expected caret and flair to both show as equipped" + + # Equipped caret color should now override --caret-color on the + # typing screen itself, not just in the store's own UI. + page.click('[data-action="menu"]') + page.wait_for_timeout(300) + page.click('[data-action="start"]') + page.wait_for_timeout(500) + caret_color = page.evaluate( + "() => getComputedStyle(document.querySelector('.typing-game')).getPropertyValue('--caret-color').trim()" + ) + assert caret_color.lower() == "#00e5ff", f"expected cyan caret override, got {caret_color!r}" + + print("test_account_and_store: PASS") + finally: + browser.close() + + +if __name__ == "__main__": + run() diff --git a/web/tests/test_leaderboard_and_friends.py b/web/tests/test_leaderboard_and_friends.py new file mode 100644 index 0000000..fc8de3e --- /dev/null +++ b/web/tests/test_leaderboard_and_friends.py @@ -0,0 +1,60 @@ +"""Leaderboard mode/device filtering, and the friends request -> accept +flow between two real accounts. See README.md to run. +""" +from playwright.sync_api import sync_playwright +from helpers import FRONTEND_URL, random_username, register_and_login + + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch() + try: + page_a = browser.new_page(viewport={"width": 1280, "height": 900}) + page_b = browser.new_page(viewport={"width": 1280, "height": 900}) + + user_a = random_username("friend_a") + user_b = random_username("friend_b") + register_and_login(page_a, user_a) + register_and_login(page_b, user_b) + + # Leaderboard: mode picker and device filter both render and + # don't error, even with no results yet for a fresh mode. + page_a.click('[data-action="leaderboard"]') + page_a.wait_for_selector(".leaderboard-modes", timeout=10000) + page_a.click('[data-action="toggle-device-filter"]') + page_a.wait_for_timeout(400) + # Case-insensitive: this button's text may render uppercase via + # CSS text-transform, which inner_text() reflects. + assert "desktop only" in page_a.locator('[data-action="toggle-device-filter"]').inner_text().lower() + assert page_a.locator(".stats-empty, .leaderboard-row").count() > 0, "leaderboard body did not render" + page_a.click('[data-action="menu"]') + page_a.wait_for_timeout(300) + + # Friends: A sends a request to B, B accepts, both sides settle + # into the Friends list. + page_a.click('[data-action="rail-friends"]') + page_a.wait_for_selector(".friends-add-form", timeout=10000) + page_a.fill('.friends-add-form input[name="username"]', user_b) + page_a.click('.friends-add-form button[type="submit"]') + page_a.wait_for_timeout(600) + + page_b.click('[data-action="rail-friends"]') + page_b.wait_for_selector('[data-action="accept"]', timeout=10000) + page_b.click('[data-action="accept"]') + page_b.wait_for_timeout(600) + + assert page_b.locator(".leaderboard-row", has_text=user_a).count() > 0, "B does not see A as a friend" + + page_a.reload() + page_a.wait_for_timeout(500) + page_a.click('[data-action="rail-friends"]') + page_a.wait_for_timeout(600) + assert page_a.locator(".leaderboard-row", has_text=user_b).count() > 0, "A does not see B as a friend" + + print("test_leaderboard_and_friends: PASS") + finally: + browser.close() + + +if __name__ == "__main__": + run() diff --git a/web/tests/test_typing_flow.py b/web/tests/test_typing_flow.py new file mode 100644 index 0000000..251ff56 --- /dev/null +++ b/web/tests/test_typing_flow.py @@ -0,0 +1,54 @@ +"""Core golden path: main menu loads, a typing test can be started, typed +characters show correct/incorrect feedback and live stats, and finishing +reaches the end screen with a results graph. See README.md to run. +""" +from playwright.sync_api import sync_playwright +from helpers import FRONTEND_URL + + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch() + try: + page = browser.new_page(viewport={"width": 1280, "height": 900}) + page.goto(FRONTEND_URL, timeout=40000) + + assert page.locator(".main-menu").is_visible(), "main menu did not render" + # inner_text() reflects CSS text-transform (this heading may be + # rendered uppercase) - compare case-insensitively rather than + # asserting on the exact source casing. + assert "typerpunk" in page.locator(".main-menu h1").inner_text().lower() + + page.click('[data-action="start"]') + page.wait_for_selector(".typing-game", timeout=10000) + + input_el = page.locator(".typing-input") + input_el.click() + page.keyboard.type("abcdefghij", delay=30) + page.wait_for_timeout(300) + + wpm_text = page.locator('[data-field="wpm"]').inner_text() + assert wpm_text.strip() != "", "live WPM stat did not render" + + classes = page.locator(".text-display span[class]").first.get_attribute("class") + assert classes is not None, "typed characters have no highlight class" + + # Finish the test by typing enough characters to exhaust the + # visible text sample - Tab is the quick-restart shortcut, not + # a way to finish, so this has to actually type to completion. + text_len = len(page.locator(".text-display").inner_text().replace("\n", " ")) + remaining = max(0, text_len - 10) + if remaining > 0: + page.keyboard.type("a" * remaining, delay=1) + + page.wait_for_selector(".end-screen", timeout=15000) + assert page.locator(".end-screen-graph-row canvas").is_visible(), "results graph did not render" + assert page.locator(".end-screen .stat-value").first.is_visible(), "end screen stats missing" + + print("test_typing_flow: PASS") + finally: + browser.close() + + +if __name__ == "__main__": + run() |