srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/tests
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-09-11 21:53:00 +0200
committersrdusr <[email protected]>2025-09-11 21:53:00 +0200
commita726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch)
tree4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /web/tests
parent8c95e90fa54db565919ec323818f77e7256812c8 (diff)
downloadtyperpunk-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.md42
-rw-r--r--web/tests/helpers.py38
-rw-r--r--web/tests/run_all.py42
-rw-r--r--web/tests/test_account_and_store.py56
-rw-r--r--web/tests/test_leaderboard_and_friends.py60
-rw-r--r--web/tests/test_typing_flow.py54
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()