slop game oder so
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""Gemeinsame pytest-Fixtures: App mit temporärer Datenbank, Test-Client, Run-Tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
from itsdangerous.timed import TimestampSigner
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_path(tmp_path: Path) -> Path:
|
||||
return tmp_path / "data" / "test.sqlite3"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(db_path: Path) -> Iterator[Flask]:
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SECRET_KEY": "test-secret-key",
|
||||
"DATABASE_PATH": str(db_path),
|
||||
"RATE_LIMIT_MAX_SUBMISSIONS": 5,
|
||||
"RATE_LIMIT_WINDOW_SECONDS": 600,
|
||||
}
|
||||
)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app: Flask) -> FlaskClient:
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def issue_token(client: FlaskClient, monkeypatch: pytest.MonkeyPatch) -> Callable[..., str]:
|
||||
"""Holt ein Run-Token über die API; ``started_ago`` simuliert eine bereits laufende Runde.
|
||||
|
||||
Die Signatur enthält den Ausgabezeitpunkt. Um eine lange Runde zu simulieren, ohne
|
||||
wirklich zu warten, wird der Zeitstempel beim Signieren zurückdatiert.
|
||||
"""
|
||||
|
||||
def _issue(started_ago: int = 0) -> str:
|
||||
original = TimestampSigner.get_timestamp
|
||||
monkeypatch.setattr(
|
||||
TimestampSigner, "get_timestamp", lambda _self: int(time.time()) - started_ago
|
||||
)
|
||||
try:
|
||||
response = client.post("/api/runs")
|
||||
finally:
|
||||
monkeypatch.setattr(TimestampSigner, "get_timestamp", original)
|
||||
assert response.status_code == 201
|
||||
return response.get_json()["run_token"]
|
||||
|
||||
return _issue
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def valid_payload(issue_token: Callable[..., str]) -> Callable[..., dict]:
|
||||
"""Erzeugt eine gültige Einreichung; einzelne Felder lassen sich überschreiben."""
|
||||
|
||||
def _payload(**overrides: object) -> dict:
|
||||
payload: dict = {
|
||||
"name": "Kadettin Ada",
|
||||
"score": 1234,
|
||||
"wave": 3,
|
||||
"duration_s": 95,
|
||||
"run_token": issue_token(started_ago=120),
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
return _payload
|
||||
@@ -0,0 +1,254 @@
|
||||
"""End-to-End-Smoke-Tests im echten Browser (Playwright, headless).
|
||||
|
||||
Die Tests starten die Flask-App selbst (temporäre Datenbank, zufälliger Port) und
|
||||
verwenden standardmäßig ein installiertes Microsoft Edge oder Google Chrome. Mit
|
||||
``GLORY_E2E_BROWSER`` lässt sich ein Kanal erzwingen (``msedge``, ``chrome`` oder
|
||||
``chromium`` für den von Playwright geladenen Browser). Ist kein Browser verfügbar,
|
||||
werden die Tests mit Begründung übersprungen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
|
||||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
expect = sync_api.expect
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
GAME = "window.gloryDefense"
|
||||
|
||||
|
||||
def _channels() -> list[str | None]:
|
||||
forced = os.environ.get("GLORY_E2E_BROWSER", "").strip()
|
||||
if forced:
|
||||
return [None if forced == "chromium" else forced]
|
||||
return ["msedge", "chrome", None]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def live_server(tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
db_path: Path = tmp_path_factory.mktemp("e2e") / "e2e.sqlite3"
|
||||
app = create_app({"TESTING": True, "SECRET_KEY": "e2e-secret", "DATABASE_PATH": str(db_path)})
|
||||
server = make_server("127.0.0.1", 0, app, threaded=True)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{server.server_port}"
|
||||
server.shutdown()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def browser() -> Iterator[object]:
|
||||
with sync_api.sync_playwright() as playwright:
|
||||
failures: list[str] = []
|
||||
for channel in _channels():
|
||||
try:
|
||||
launched = playwright.chromium.launch(channel=channel, headless=True)
|
||||
except Exception as exc: # jeder Startfehler heißt: nächsten Kandidaten probieren
|
||||
failures.append(f"{channel or 'chromium'}: {str(exc).splitlines()[0]}")
|
||||
continue
|
||||
yield launched
|
||||
launched.close()
|
||||
return
|
||||
pytest.skip("Kein Browser für Playwright gefunden – " + " | ".join(failures))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def page(browser: object, live_server: str) -> Iterator[object]:
|
||||
context = browser.new_context(viewport={"width": 1280, "height": 720})
|
||||
page = context.new_page()
|
||||
page.errors = [] # type: ignore[attr-defined]
|
||||
|
||||
def on_console(msg: object) -> None:
|
||||
if msg.type == "error":
|
||||
page.errors.append(f"console.{msg.type}: {msg.text}")
|
||||
|
||||
page.on("pageerror", lambda exc: page.errors.append(f"pageerror: {exc}"))
|
||||
page.on("console", on_console)
|
||||
page.goto(live_server + "/")
|
||||
yield page
|
||||
context.close()
|
||||
|
||||
|
||||
def _wait_until(page: object, expression: str, timeout_s: float = 15.0) -> None:
|
||||
"""Wartet, bis ``expression`` im Browser wahr ist.
|
||||
|
||||
Absichtlich nicht ``page.wait_for_function``: Das wertet den Ausdruck per ``eval``
|
||||
aus, was die Content-Security-Policy der App (kein ``unsafe-eval``) zu Recht
|
||||
blockiert. ``page.evaluate`` läuft über das DevTools-Protokoll und ist davon
|
||||
nicht betroffen – so testen wir mit der echten, unveränderten CSP.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if page.evaluate(f"() => Boolean({expression})"):
|
||||
return
|
||||
page.wait_for_timeout(100)
|
||||
raise AssertionError(f"Zeitüberschreitung beim Warten auf: {expression}")
|
||||
|
||||
|
||||
def _wait_for_scene(page: object, key: str) -> None:
|
||||
_wait_until(page, f"{GAME} && {GAME}.scene.isActive('{key}')")
|
||||
|
||||
|
||||
def _accept_disclaimer_and_start(page: object) -> None:
|
||||
accept = page.get_by_test_id("disclaimer-accept")
|
||||
expect(accept).to_be_visible()
|
||||
accept.click()
|
||||
_wait_for_scene(page, "Menu")
|
||||
page.locator("#game canvas").click(position={"x": 40, "y": 40})
|
||||
page.keyboard.press("Enter")
|
||||
_wait_for_scene(page, "Briefing")
|
||||
page.keyboard.press("Space")
|
||||
page.keyboard.press("Space")
|
||||
_wait_for_scene(page, "Game")
|
||||
_wait_for_scene(page, "Hud")
|
||||
|
||||
|
||||
def test_page_loads_disclaimer_then_game_starts(page: object) -> None:
|
||||
expect(page).to_have_title("Glory Defense")
|
||||
_accept_disclaimer_and_start(page)
|
||||
assert page.evaluate("localStorage.getItem('gloryDefense.disclaimerAccepted.v1')") == "true"
|
||||
|
||||
# Etwas spielen: nach rechts laufen, zielen und Dauerfeuer geben.
|
||||
page.keyboard.down("d")
|
||||
page.mouse.move(1000, 300)
|
||||
page.mouse.down()
|
||||
page.wait_for_timeout(1200)
|
||||
page.mouse.up()
|
||||
page.keyboard.up("d")
|
||||
|
||||
_wait_until(page, f"{GAME}.scene.getScene('Game').gameTime > 1500")
|
||||
_wait_until(page, f"{GAME}.scene.getScene('Game').runToken !== null")
|
||||
state = page.evaluate(
|
||||
f"""() => {{
|
||||
const s = {GAME}.scene.getScene('Game');
|
||||
return {{ magazine: s.player.weapon.magazine, x: s.player.x }};
|
||||
}}"""
|
||||
)
|
||||
assert state["magazine"] < 30, "Es wurde nicht geschossen"
|
||||
assert state["x"] > 1200, "Der Präsident hat sich nicht bewegt"
|
||||
|
||||
# Pause per Esc, Lautstärke ändern, weiterspielen.
|
||||
page.keyboard.press("Escape")
|
||||
resume = page.get_by_test_id("pause-resume")
|
||||
expect(resume).to_be_visible()
|
||||
page.get_by_test_id("volume-music").fill("40")
|
||||
resume.click()
|
||||
_wait_for_scene(page, "Game")
|
||||
|
||||
assert page.errors == [] # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_orbital_commands_via_keyboard(page: object) -> None:
|
||||
_accept_disclaimer_and_start(page)
|
||||
page.mouse.move(800, 360)
|
||||
|
||||
# Orbitalschlag mit Pfeiltasten, Nachschub mit WASD – beides bei gehaltenem Q.
|
||||
page.keyboard.down("q")
|
||||
for key in ("ArrowUp", "ArrowRight", "ArrowDown", "ArrowDown"):
|
||||
page.keyboard.press(key)
|
||||
for key in ("a", "a", "s", "d"):
|
||||
page.keyboard.press(key)
|
||||
page.keyboard.up("q")
|
||||
|
||||
_wait_until(page, f"{GAME}.scene.getScene('Game').orbital.pending.length === 2", timeout_s=5)
|
||||
ready = page.evaluate(
|
||||
f"""() => {{
|
||||
const s = {GAME}.scene.getScene('Game');
|
||||
return s.orbital.hudState(s.gameTime).commands.map(c => [c.id, c.ready]);
|
||||
}}"""
|
||||
)
|
||||
assert ready == [["strike", False], ["supply", False], ["turret", True]]
|
||||
# Nach dem Einschlag liegt eine Nachschubkapsel in der Arena.
|
||||
_wait_until(page, f"{GAME}.scene.getScene('Game').pods.countActive(true) === 1", timeout_s=8)
|
||||
assert page.errors == [] # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_disclaimer_is_remembered_and_reachable_from_menu(page: object) -> None:
|
||||
page.get_by_test_id("disclaimer-accept").click()
|
||||
_wait_for_scene(page, "Menu")
|
||||
page.reload()
|
||||
_wait_for_scene(page, "Menu")
|
||||
expect(page.locator("#disclaimer-overlay")).to_have_count(0)
|
||||
|
||||
# Im Menü: 4. Eintrag „Disclaimer“ (dreimal nach unten, Enter).
|
||||
page.locator("#game canvas").click(position={"x": 40, "y": 40})
|
||||
for _ in range(3):
|
||||
page.keyboard.press("ArrowDown")
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.locator("#disclaimer-overlay")).to_be_visible()
|
||||
expect(page.locator("#disclaimer-overlay")).to_contain_text("Satire und reine Fiktion")
|
||||
page.get_by_test_id("disclaimer-accept").click()
|
||||
expect(page.locator("#disclaimer-overlay")).to_have_count(0)
|
||||
assert page.errors == [] # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_game_over_submits_highscore(page: object) -> None:
|
||||
_accept_disclaimer_and_start(page)
|
||||
_wait_until(page, f"{GAME}.scene.getScene('Game').runToken !== null")
|
||||
_wait_until(page, f"{GAME}.scene.getScene('Game').gameTime > 2000")
|
||||
# Test-Abkürzung: Punkte gutschreiben und den Präsidenten fallen lassen.
|
||||
page.evaluate(
|
||||
f"""() => {{
|
||||
const s = {GAME}.scene.getScene('Game');
|
||||
s.score.addBonus(150);
|
||||
s.player.hitInvulnerableUntil = 0;
|
||||
s.player.dodgeUntil = 0;
|
||||
s.damagePlayer(9999);
|
||||
}}"""
|
||||
)
|
||||
_wait_for_scene(page, "GameOver")
|
||||
name = page.get_by_test_id("highscore-name")
|
||||
expect(name).to_be_visible()
|
||||
name.fill("E2E Kadett")
|
||||
page.get_by_test_id("highscore-submit").click()
|
||||
expect(page.locator(".highscore-list__own")).to_contain_text("E2E Kadett")
|
||||
expect(page.locator("#gameover-overlay")).to_contain_text("Platz 1")
|
||||
assert page.errors == [] # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_game_runs_with_placeholders_when_assets_are_missing(
|
||||
browser: object, live_server: str
|
||||
) -> None:
|
||||
"""Abnahmekriterium: Das Spiel ist auch ohne echte Grafiken vollständig spielbar."""
|
||||
context = browser.new_context(viewport={"width": 1280, "height": 720})
|
||||
page = context.new_page()
|
||||
page.errors = [] # type: ignore[attr-defined]
|
||||
page.on("pageerror", lambda exc: page.errors.append(str(exc)))
|
||||
# Sämtliche Grafik-, Schrift- und Klangdateien blockieren.
|
||||
page.route("**/static/assets/**", lambda route: route.abort())
|
||||
page.goto(live_server + "/")
|
||||
_accept_disclaimer_and_start(page)
|
||||
_wait_until(page, f"{GAME}.scene.getScene('Game').gameTime > 2500")
|
||||
textures = page.evaluate(
|
||||
f"""() => {{
|
||||
const t = {GAME}.textures;
|
||||
return ['player', 'enemy-krabbler', 'enemy-walzmeister', 'supply-pod', 'emblem']
|
||||
.map(key => [key, t.exists(key), t.get(key).source[0].width]);
|
||||
}}"""
|
||||
)
|
||||
# Platzhalter existieren (Größen der Platzhalter, nicht der SVGs).
|
||||
assert all(exists for _key, exists, _width in textures)
|
||||
assert dict((key, width) for key, _e, width in textures)["player"] != 64
|
||||
assert page.errors == [] # type: ignore[attr-defined]
|
||||
context.close()
|
||||
|
||||
|
||||
def test_disclaimer_page_works_without_javascript(browser: object, live_server: str) -> None:
|
||||
context = browser.new_context(java_script_enabled=False)
|
||||
page = context.new_page()
|
||||
page.goto(live_server + "/disclaimer")
|
||||
expect(page.locator("main")).to_contain_text("frei erfunden")
|
||||
expect(page.get_by_role("link", name="Zurück zum Spiel")).to_be_visible()
|
||||
context.close()
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiClient } from '../../app/static/js/systems/ApiClient.js';
|
||||
|
||||
const endpoints = { highscores: '/api/highscores', runs: '/api/runs' };
|
||||
|
||||
/** Baut eine fetch-Attrappe mit fester Antwort. */
|
||||
function fakeFetch(status, body) {
|
||||
return vi.fn(async () => ({
|
||||
status,
|
||||
json: async () => {
|
||||
if (body === undefined) throw new SyntaxError('kein JSON');
|
||||
return body;
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
describe('ApiClient', () => {
|
||||
it('holt ein Run-Token', async () => {
|
||||
const fetch = fakeFetch(201, { run_token: 'abc' });
|
||||
expect(await new ApiClient(endpoints, fetch).startRun()).toBe('abc');
|
||||
expect(fetch).toHaveBeenCalledWith('/api/runs', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
it('liefert null statt Fehler, wenn der Server nicht erreichbar ist', async () => {
|
||||
const fetch = vi.fn(async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
});
|
||||
const client = new ApiClient(endpoints, fetch);
|
||||
expect(await client.startRun()).toBeNull();
|
||||
expect(await client.fetchHighscores()).toEqual({ ok: false, highscores: [] });
|
||||
expect(await client.submitHighscore({ name: 'A', score: 1, wave: 1, durationS: 1, runToken: 't' })).toMatchObject({
|
||||
ok: false,
|
||||
error: 'network',
|
||||
});
|
||||
});
|
||||
|
||||
it('liest die Highscore-Liste', async () => {
|
||||
const entries = [{ rank: 1, name: 'Ada', score: 10, wave: 1, created_at: 'x' }];
|
||||
const result = await new ApiClient(endpoints, fakeFetch(200, { highscores: entries })).fetchHighscores();
|
||||
expect(result).toEqual({ ok: true, highscores: entries });
|
||||
});
|
||||
|
||||
it('behandelt kaputte Antworten als Fehlschlag', async () => {
|
||||
expect(await new ApiClient(endpoints, fakeFetch(200, undefined)).fetchHighscores()).toEqual({ ok: false, highscores: [] });
|
||||
expect(await new ApiClient(endpoints, fakeFetch(500, { error: 'x' })).startRun()).toBeNull();
|
||||
});
|
||||
|
||||
it('sendet Highscores im API-Format mit ganzzahligen Werten', async () => {
|
||||
const fetch = fakeFetch(201, { rank: 3, highscores: [] });
|
||||
const result = await new ApiClient(endpoints, fetch).submitHighscore({
|
||||
name: 'Ada',
|
||||
score: 1234.7,
|
||||
wave: 3,
|
||||
durationS: 95.9,
|
||||
runToken: 'tok',
|
||||
});
|
||||
expect(result).toEqual({ ok: true, rank: 3, highscores: [] });
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('/api/highscores');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
expect(JSON.parse(init.body)).toEqual({ name: 'Ada', score: 1234, wave: 3, duration_s: 95, run_token: 'tok' });
|
||||
});
|
||||
|
||||
it('gibt Validierungs- und Rate-Limit-Fehler lesbar zurück', async () => {
|
||||
const invalid = await new ApiClient(
|
||||
endpoints,
|
||||
fakeFetch(400, { error: 'validation_failed', message: 'Ungültige Eingaben.', fields: { name: 'zu lang' } }),
|
||||
).submitHighscore({ name: 'A', score: 1, wave: 1, durationS: 1, runToken: 't' });
|
||||
expect(invalid).toMatchObject({ ok: false, error: 'validation_failed', fields: { name: 'zu lang' } });
|
||||
|
||||
const limited = await new ApiClient(endpoints, fakeFetch(429, { error: 'rate_limited', message: 'Zu viele' })).submitHighscore({
|
||||
name: 'A',
|
||||
score: 1,
|
||||
wave: 1,
|
||||
durationS: 1,
|
||||
runToken: 't',
|
||||
});
|
||||
expect(limited).toMatchObject({ ok: false, error: 'rate_limited', message: 'Zu viele' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
BARS,
|
||||
CHORD_ROOTS,
|
||||
eventsAtStep,
|
||||
midiToFrequency,
|
||||
STEPS_PER_BAR,
|
||||
TOTAL_STEPS,
|
||||
} from '../../app/static/js/logic/marchPattern.js';
|
||||
import { allSoundFiles, AUDIO_PATH, SOUND_FILES, SOUND_OPTIONS } from '../../app/static/js/systems/AudioAssets.js';
|
||||
import { AudioManager } from '../../app/static/js/systems/AudioManager.js';
|
||||
import { SafeStorage, Settings } from '../../app/static/js/systems/Settings.js';
|
||||
import { SYNTH_RECIPES } from '../../app/static/js/systems/Synth.js';
|
||||
|
||||
const ASSET_DIR = join(import.meta.dirname, '..', '..', 'app', 'static', 'assets', AUDIO_PATH);
|
||||
|
||||
// ------------------------------------------------------------ minimaler AudioContext-Ersatz
|
||||
|
||||
class FakeParam {
|
||||
constructor(value = 1) {
|
||||
this.value = value;
|
||||
}
|
||||
setValueAtTime(v) {
|
||||
this.value = v;
|
||||
return this;
|
||||
}
|
||||
exponentialRampToValueAtTime() {
|
||||
return this;
|
||||
}
|
||||
setTargetAtTime(v) {
|
||||
this.value = v;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNode {
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
connect(node) {
|
||||
return node;
|
||||
}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
class FakeContext {
|
||||
constructor() {
|
||||
this.state = 'running';
|
||||
this.currentTime = 0;
|
||||
this.sampleRate = 8000;
|
||||
this.destination = new FakeNode(this);
|
||||
this.created = { oscillator: 0, source: 0 };
|
||||
this.startedBuffers = [];
|
||||
}
|
||||
createGain() {
|
||||
return Object.assign(new FakeNode(this), { gain: new FakeParam(1) });
|
||||
}
|
||||
createOscillator() {
|
||||
this.created.oscillator += 1;
|
||||
return Object.assign(new FakeNode(this), { frequency: new FakeParam(440), type: 'sine', start() {}, stop() {} });
|
||||
}
|
||||
createBufferSource() {
|
||||
this.created.source += 1;
|
||||
const ctx = this;
|
||||
return Object.assign(new FakeNode(this), {
|
||||
playbackRate: new FakeParam(1),
|
||||
buffer: null,
|
||||
start() {
|
||||
ctx.startedBuffers.push(this.buffer);
|
||||
},
|
||||
stop() {},
|
||||
});
|
||||
}
|
||||
createBiquadFilter() {
|
||||
return Object.assign(new FakeNode(this), { frequency: new FakeParam(350), Q: new FakeParam(1), type: 'lowpass' });
|
||||
}
|
||||
createBuffer(_channels, length) {
|
||||
const data = new Float32Array(length);
|
||||
return { getChannelData: () => data };
|
||||
}
|
||||
decodeAudioData(bytes) {
|
||||
return Promise.resolve({ decodedFrom: bytes.byteLength });
|
||||
}
|
||||
resume() {
|
||||
this.state = 'running';
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function memorySettings() {
|
||||
const data = new Map();
|
||||
return new Settings(new SafeStorage({ getItem: (k) => data.get(k) ?? null, setItem: (k, v) => data.set(k, v) }));
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// ------------------------------------------------------------------------------ Tests
|
||||
|
||||
describe('AudioAssets', () => {
|
||||
it('verweist nur auf vorhandene Dateien', () => {
|
||||
const missing = allSoundFiles().filter((file) => !existsSync(join(ASSET_DIR, file)));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('hat für jeden Effekt einen synthetischen Ersatz (Spiel bleibt ohne Dateien hörbar)', () => {
|
||||
const withoutRecipe = Object.keys(SOUND_FILES).filter((name) => typeof SYNTH_RECIPES[name] !== 'function');
|
||||
expect(withoutRecipe).toEqual([]);
|
||||
});
|
||||
|
||||
it('hat für jeden Effekt Abspieloptionen mit gültiger Lautstärke', () => {
|
||||
for (const name of Object.keys(SOUND_FILES)) {
|
||||
const volume = SOUND_OPTIONS[name]?.volume ?? 1;
|
||||
expect(volume, name).toBeGreaterThan(0);
|
||||
expect(volume, name).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AudioManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('AudioContext', FakeContext);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('bleibt vor der ersten Nutzeraktion stumm, ohne Fehler', () => {
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
||||
expect(() => audio.play('shot-carbine')).not.toThrow();
|
||||
expect(audio.context).toBeNull();
|
||||
});
|
||||
|
||||
it('lädt Dateien vorab und spielt sie nach dem Entsperren ab', async () => {
|
||||
const fetchImpl = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(16) }));
|
||||
const audio = new AudioManager(memorySettings(), { staticBase: '/glory/static/', fetchImpl });
|
||||
audio.preload();
|
||||
expect(fetchImpl).toHaveBeenCalledWith(`/glory/static/assets/${AUDIO_PATH}laserSmall_000.ogg`);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(allSoundFiles().length);
|
||||
|
||||
audio.unlock();
|
||||
await flush();
|
||||
await flush();
|
||||
audio.play('shot-carbine');
|
||||
expect(audio.context.startedBuffers).toHaveLength(1);
|
||||
expect(audio.context.startedBuffers[0]).toEqual({ decodedFrom: 16 });
|
||||
});
|
||||
|
||||
it('weicht auf den Synthesizer aus, wenn Dateien fehlen', async () => {
|
||||
const fetchImpl = vi.fn(async () => ({ ok: false }));
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl });
|
||||
audio.preload();
|
||||
audio.unlock();
|
||||
await flush();
|
||||
audio.play('explosion-big');
|
||||
// Gespielt wurde nur Rauschen aus dem Synthesizer, keine dekodierte Datei.
|
||||
expect(audio.context.startedBuffers.filter((b) => b && 'decodedFrom' in b)).toHaveLength(0);
|
||||
expect(audio.context.created.oscillator).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('begrenzt schnelle Wiederholungen desselben Effekts', () => {
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
||||
audio.unlock();
|
||||
const before = audio.context.created.oscillator;
|
||||
audio.play('combo-step');
|
||||
audio.play('combo-step');
|
||||
expect(audio.context.created.oscillator - before).toBe(1);
|
||||
});
|
||||
|
||||
it('überträgt Lautstärkeänderungen sofort auf die Kanäle', () => {
|
||||
const settings = memorySettings();
|
||||
const audio = new AudioManager(settings, { fetchImpl: vi.fn() });
|
||||
audio.unlock();
|
||||
settings.setVolume('music', 0.2);
|
||||
settings.setVolume('master', 0.6);
|
||||
expect(audio.gains.music.gain.value).toBe(0.2);
|
||||
expect(audio.gains.master.gain.value).toBe(0.6);
|
||||
});
|
||||
|
||||
it('startet vorgemerkte Musik erst nach dem Entsperren und stoppt sie wieder', () => {
|
||||
vi.useFakeTimers();
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
||||
audio.startMusic();
|
||||
expect(audio.music).toBeNull();
|
||||
audio.unlock();
|
||||
expect(audio.music).not.toBeNull();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(audio.context.created.oscillator).toBeGreaterThan(0);
|
||||
audio.stopMusic();
|
||||
expect(audio.music).toBeNull();
|
||||
expect(audio.musicWanted).toBe(false);
|
||||
});
|
||||
|
||||
it('senkt die Musik in der Pause ab', () => {
|
||||
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
||||
audio.unlock();
|
||||
audio.duck(true);
|
||||
expect(audio.gains.duck.gain.value).toBeLessThan(1);
|
||||
audio.duck(false);
|
||||
expect(audio.gains.duck.gain.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Marschmuster', () => {
|
||||
const all = Array.from({ length: TOTAL_STEPS }, (_, step) => eventsAtStep(step));
|
||||
const count = (instrument) => all.flat().filter((e) => e.instrument === instrument).length;
|
||||
|
||||
it('hat 8 Takte à 16 Schritte', () => {
|
||||
expect(TOTAL_STEPS).toBe(BARS * STEPS_PER_BAR);
|
||||
expect(CHORD_ROOTS).toHaveLength(BARS);
|
||||
});
|
||||
|
||||
it('schlägt die große Trommel auf 1 und 3 jedes Takts', () => {
|
||||
expect(count('kick')).toBe(BARS * 2);
|
||||
for (let bar = 0; bar < BARS; bar += 1) {
|
||||
expect(all[bar * STEPS_PER_BAR].some((e) => e.instrument === 'kick')).toBe(true);
|
||||
expect(all[bar * STEPS_PER_BAR + 8].some((e) => e.instrument === 'kick')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('spielt die Melodie nur mit Tönen aus C-Dur', () => {
|
||||
const cMajor = new Set([0, 2, 4, 5, 7, 9, 11]);
|
||||
const melody = all.flat().filter((e) => e.instrument === 'lead');
|
||||
expect(melody.length).toBeGreaterThan(20);
|
||||
for (const event of melody) expect(cMajor.has(event.notes[0] % 12)).toBe(true);
|
||||
});
|
||||
|
||||
it('wiederholt sich nahtlos', () => {
|
||||
expect(eventsAtStep(TOTAL_STEPS)).toEqual(eventsAtStep(0));
|
||||
expect(eventsAtStep(-1)).toEqual(eventsAtStep(TOTAL_STEPS - 1));
|
||||
});
|
||||
|
||||
it('rechnet MIDI-Töne in Frequenzen um', () => {
|
||||
expect(midiToFrequency(69)).toBeCloseTo(440);
|
||||
expect(midiToFrequency(57)).toBeCloseTo(220);
|
||||
expect(midiToFrequency(60)).toBeCloseTo(261.63, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ORBITAL } from '../../app/static/js/config.js';
|
||||
import {
|
||||
codeToDirection,
|
||||
ComboInput,
|
||||
isPrefix,
|
||||
validateCommands,
|
||||
} from '../../app/static/js/logic/comboInput.js';
|
||||
|
||||
const COMMANDS = [
|
||||
{ id: 'strike', sequence: ['up', 'right', 'down', 'down'] },
|
||||
{ id: 'supply', sequence: ['down', 'down', 'up', 'right'] },
|
||||
{ id: 'turret', sequence: ['down', 'up', 'right', 'right', 'up'] },
|
||||
];
|
||||
|
||||
const typeAll = (combo, dirs) => dirs.map((d) => combo.push(d));
|
||||
|
||||
describe('codeToDirection', () => {
|
||||
it('bildet Pfeiltasten und WASD (physische Position) ab', () => {
|
||||
expect(codeToDirection('ArrowUp')).toBe('up');
|
||||
expect(codeToDirection('KeyW')).toBe('up');
|
||||
expect(codeToDirection('KeyA')).toBe('left');
|
||||
expect(codeToDirection('ArrowRight')).toBe('right');
|
||||
expect(codeToDirection('KeyS')).toBe('down');
|
||||
expect(codeToDirection('KeyQ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrefix', () => {
|
||||
it('erkennt Anfänge von Folgen', () => {
|
||||
expect(isPrefix([], ['up'])).toBe(true);
|
||||
expect(isPrefix(['up'], ['up', 'down'])).toBe(true);
|
||||
expect(isPrefix(['down'], ['up', 'down'])).toBe(false);
|
||||
expect(isPrefix(['up', 'down', 'up'], ['up', 'down'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCommands', () => {
|
||||
it('akzeptiert die Befehle aus der Konfiguration', () => {
|
||||
expect(() => validateCommands(ORBITAL.commands)).not.toThrow();
|
||||
});
|
||||
|
||||
it('lehnt Folgen ab, die Anfang einer anderen sind', () => {
|
||||
expect(() =>
|
||||
validateCommands([
|
||||
{ id: 'a', sequence: ['up', 'down'] },
|
||||
{ id: 'b', sequence: ['up', 'down', 'left'] },
|
||||
]),
|
||||
).toThrow(/Anfang/);
|
||||
});
|
||||
|
||||
it('lehnt doppelte IDs, leere Folgen und unbekannte Richtungen ab', () => {
|
||||
expect(() => validateCommands([{ id: 'a', sequence: ['up'] }, { id: 'a', sequence: ['down'] }])).toThrow();
|
||||
expect(() => validateCommands([{ id: 'a', sequence: [] }])).toThrow();
|
||||
expect(() => validateCommands([{ id: 'a', sequence: ['oben'] }])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ComboInput', () => {
|
||||
it('ignoriert Eingaben, solange die Halte-Taste nicht gedrückt ist', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
expect(combo.push('up').status).toBe('inactive');
|
||||
});
|
||||
|
||||
it('erkennt eine vollständige Folge', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
const results = typeAll(combo, ['up', 'right', 'down', 'down']);
|
||||
expect(results.slice(0, 3).map((r) => r.status)).toEqual(['progress', 'progress', 'progress']);
|
||||
expect(results[3]).toEqual({ status: 'match', commandId: 'strike' });
|
||||
expect(combo.buffer).toEqual([]);
|
||||
});
|
||||
|
||||
it('meldet passende Kandidaten während der Eingabe', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
expect(combo.push('down')).toEqual({ status: 'progress', candidates: ['supply', 'turret'] });
|
||||
expect(combo.push('up')).toEqual({ status: 'progress', candidates: ['turret'] });
|
||||
});
|
||||
|
||||
it('meldet Fehleingaben und erlaubt direkt einen neuen Versuch', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
combo.push('up');
|
||||
expect(combo.push('up').status).toBe('fail');
|
||||
expect(combo.buffer).toEqual([]);
|
||||
const retry = typeAll(combo, ['down', 'down', 'up', 'right']);
|
||||
expect(retry.at(-1)).toEqual({ status: 'match', commandId: 'supply' });
|
||||
});
|
||||
|
||||
it('erlaubt mehrere Befehle hintereinander, solange die Taste gehalten wird', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
expect(typeAll(combo, ['up', 'right', 'down', 'down']).at(-1).commandId).toBe('strike');
|
||||
expect(typeAll(combo, ['down', 'up', 'right', 'right', 'up']).at(-1).commandId).toBe('turret');
|
||||
});
|
||||
|
||||
it('verwirft halbe Eingaben beim Loslassen', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
typeAll(combo, ['up', 'right']);
|
||||
combo.end();
|
||||
combo.begin();
|
||||
expect(combo.push('down').status).toBe('progress');
|
||||
expect(combo.buffer).toEqual(['down']);
|
||||
});
|
||||
|
||||
it('ignoriert unbekannte Richtungen ohne den Puffer zu verändern', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
combo.push('up');
|
||||
expect(combo.push('diagonal').status).toBe('ignored');
|
||||
expect(combo.buffer).toEqual(['up']);
|
||||
});
|
||||
|
||||
it('zeigt den Fortschritt je Befehl für das HUD', () => {
|
||||
const combo = new ComboInput(COMMANDS);
|
||||
combo.begin();
|
||||
typeAll(combo, ['down', 'down']);
|
||||
expect(combo.progressFor('supply')).toBe(2);
|
||||
expect(combo.progressFor('turret')).toBe(0);
|
||||
expect(combo.progressFor('strike')).toBe(0);
|
||||
expect(combo.progressFor('unbekannt')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Plausibilitätsprüfungen der Balancing-Werte: fängt Tippfehler ab, bevor sie im
|
||||
* Spiel als seltsames Verhalten auffallen.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ENEMY_TYPES,
|
||||
FACTIONS,
|
||||
HIGHSCORE,
|
||||
ORBITAL,
|
||||
PLAYER,
|
||||
SCORING,
|
||||
WAVES,
|
||||
WEAPONS,
|
||||
} from '../../app/static/js/config.js';
|
||||
import { validateCommands } from '../../app/static/js/logic/comboInput.js';
|
||||
|
||||
/** Findet NaN, negative Zahlen und fehlende Werte in verschachtelten Objekten. */
|
||||
function assertSaneNumbers(obj, path = '') {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const here = `${path}.${key}`;
|
||||
if (typeof value === 'number') {
|
||||
expect(Number.isFinite(value), here).toBe(true);
|
||||
expect(value, here).toBeGreaterThanOrEqual(0);
|
||||
} else if (value && typeof value === 'object') {
|
||||
assertSaneNumbers(value, here);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('config', () => {
|
||||
it('enthält nur endliche, nicht-negative Zahlen', () => {
|
||||
for (const [name, section] of Object.entries({ PLAYER, WEAPONS, ENEMY_TYPES, WAVES, SCORING, ORBITAL })) {
|
||||
assertSaneNumbers(section, name);
|
||||
}
|
||||
});
|
||||
|
||||
it('hat je Fraktion genau einen kleinen, mittleren und Elite-Gegner', () => {
|
||||
for (const faction of Object.keys(FACTIONS)) {
|
||||
const tiers = Object.values(ENEMY_TYPES)
|
||||
.filter((t) => t.faction === faction)
|
||||
.map((t) => t.tier)
|
||||
.sort();
|
||||
expect(tiers, faction).toEqual(['elite', 'medium', 'small']);
|
||||
}
|
||||
});
|
||||
|
||||
it('verwendet konsistente IDs und gültige Gruppengrößen', () => {
|
||||
for (const [key, type] of Object.entries(ENEMY_TYPES)) {
|
||||
expect(type.id).toBe(key);
|
||||
expect(type.groupSize.min).toBeLessThanOrEqual(type.groupSize.max);
|
||||
expect(type.groupSize.min).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
for (const [key, weapon] of Object.entries(WEAPONS)) expect(weapon.id).toBe(key);
|
||||
});
|
||||
|
||||
it('lässt Fernkämpfer nur schießen, wenn sie in ihrem Abstandsband sind', () => {
|
||||
for (const type of Object.values(ENEMY_TYPES)) {
|
||||
if (!type.range) continue;
|
||||
expect(type.range.min, type.id).toBeLessThan(type.range.max);
|
||||
expect(type.attack.maxDistance, type.id).toBeGreaterThanOrEqual(type.range.max);
|
||||
}
|
||||
});
|
||||
|
||||
it('verweist nur auf existierende Waffen und Brut-Typen', () => {
|
||||
for (const slot of PLAYER.weaponSlots) expect(WEAPONS[slot]).toBeDefined();
|
||||
expect(ENEMY_TYPES[ENEMY_TYPES.brutmutter.brood.childType]).toBeDefined();
|
||||
});
|
||||
|
||||
it('hat mindestens drei gültige Orbital-Befehle mit Verzögerung und Abklingzeit', () => {
|
||||
expect(ORBITAL.commands.length).toBeGreaterThanOrEqual(3);
|
||||
expect(() => validateCommands(ORBITAL.commands)).not.toThrow();
|
||||
for (const command of ORBITAL.commands) {
|
||||
expect(command.delayMs, command.id).toBeGreaterThan(0);
|
||||
expect(command.cooldownMs, command.id).toBeGreaterThan(command.delayMs);
|
||||
}
|
||||
const ids = ORBITAL.commands.map((c) => c.id);
|
||||
expect(ids).toEqual(expect.arrayContaining(['strike', 'supply', 'turret']));
|
||||
});
|
||||
|
||||
it('verwendet eigene Befehlscodes statt Codes aus bekannten Spielen', () => {
|
||||
// Codes, die in einem bekannten Koop-Shooter mit ähnlicher Mechanik belegt sind
|
||||
// (vom unabhängigen Review als zu nah beanstandet). Keiner unserer Codes darf gleich sein.
|
||||
const taken = [
|
||||
['down', 'down', 'up', 'right'],
|
||||
['down', 'up', 'right', 'right', 'up'],
|
||||
['up', 'down', 'right', 'left', 'up'],
|
||||
['up', 'right', 'down', 'down', 'down'],
|
||||
['right', 'right', 'up'],
|
||||
['down', 'up', 'right', 'left'],
|
||||
].map((s) => s.join(','));
|
||||
for (const command of ORBITAL.commands) {
|
||||
expect(taken, command.id).not.toContain(command.sequence.join(','));
|
||||
}
|
||||
// Jeder Code beginnt mit einer anderen Richtung – schon der erste Tastendruck ist eindeutig.
|
||||
const starts = ORBITAL.commands.map((c) => c.sequence[0]);
|
||||
expect(new Set(starts).size).toBe(starts.length);
|
||||
});
|
||||
|
||||
it('nutzt als Halte-Taste weder Strg (Strg+W schließt den Tab) noch Shift (Einrastfunktion)', () => {
|
||||
expect(['CTRL', 'CONTROL', 'SHIFT']).not.toContain(ORBITAL.holdKey.toUpperCase());
|
||||
});
|
||||
|
||||
it('hält das Wellenbudget mindestens so hoch wie die teuerste Elite in Elite-Wellen', () => {
|
||||
const maxEliteCost = Math.max(...Object.values(ENEMY_TYPES).filter((t) => t.tier === 'elite').map((t) => t.cost));
|
||||
const firstEliteBudget = WAVES.firstBudget + WAVES.budgetGrowth * Math.pow(WAVES.eliteEvery - 1, WAVES.budgetExponent);
|
||||
expect(firstEliteBudget).toBeGreaterThan(maxEliteCost);
|
||||
});
|
||||
|
||||
it('stimmt die maximale Namenslänge mit dem Backend ab', () => {
|
||||
expect(HIGHSCORE.nameMaxLength).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CooldownTracker } from '../../app/static/js/logic/cooldowns.js';
|
||||
|
||||
describe('CooldownTracker', () => {
|
||||
it('ist für unbekannte Fähigkeiten sofort bereit', () => {
|
||||
const cd = new CooldownTracker();
|
||||
expect(cd.isReady('dodge', 0)).toBe(true);
|
||||
expect(cd.remaining('dodge', 0)).toBe(0);
|
||||
expect(cd.progress('dodge', 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('läuft nach dem Auslösen ab', () => {
|
||||
const cd = new CooldownTracker();
|
||||
cd.trigger('dodge', 1000, 5000);
|
||||
expect(cd.isReady('dodge', 5000)).toBe(false);
|
||||
expect(cd.remaining('dodge', 5250)).toBe(750);
|
||||
expect(cd.progress('dodge', 5250)).toBeCloseTo(0.25);
|
||||
expect(cd.isReady('dodge', 6000)).toBe(true);
|
||||
expect(cd.progress('dodge', 9000)).toBe(1);
|
||||
});
|
||||
|
||||
it('verwaltet mehrere Fähigkeiten unabhängig', () => {
|
||||
const cd = new CooldownTracker();
|
||||
cd.trigger('a', 100, 0);
|
||||
cd.trigger('b', 1000, 0);
|
||||
expect(cd.isReady('a', 150)).toBe(true);
|
||||
expect(cd.isReady('b', 150)).toBe(false);
|
||||
});
|
||||
|
||||
it('lässt sich zurücksetzen', () => {
|
||||
const cd = new CooldownTracker();
|
||||
cd.trigger('a', 100, 0);
|
||||
cd.trigger('b', 100, 0);
|
||||
cd.reset('a');
|
||||
expect(cd.isReady('a', 0)).toBe(true);
|
||||
expect(cd.isReady('b', 0)).toBe(false);
|
||||
cd.reset();
|
||||
expect(cd.isReady('b', 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ENEMY_TYPES } from '../../app/static/js/config.js';
|
||||
import {
|
||||
attackStep,
|
||||
broodStep,
|
||||
createAttackState,
|
||||
createBrain,
|
||||
flankVelocity,
|
||||
leaperStep,
|
||||
swarmVelocity,
|
||||
updateStrafe,
|
||||
volleyAngles,
|
||||
} from '../../app/static/js/logic/enemyAi.js';
|
||||
import { degToRad, distance, length } from '../../app/static/js/logic/math.js';
|
||||
import { createRng } from '../../app/static/js/logic/rng.js';
|
||||
|
||||
const player = { x: 0, y: 0 };
|
||||
|
||||
describe('createBrain', () => {
|
||||
it('verzögert den ersten Angriff und ist reproduzierbar', () => {
|
||||
const a = createBrain(createRng(1), 1000, { firstAttackDelayMs: 800 });
|
||||
const b = createBrain(createRng(1), 1000, { firstAttackDelayMs: 800 });
|
||||
expect(a).toEqual(b);
|
||||
expect(a.attack.nextAttackAt).toBeGreaterThanOrEqual(1400);
|
||||
expect(a.attack.nextAttackAt).toBeLessThan(2200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('swarmVelocity', () => {
|
||||
const type = ENEMY_TYPES.krabbler;
|
||||
|
||||
it('steuert aus der Distanz den eigenen Umzingelungsplatz an', () => {
|
||||
const brain = { slotAngle: Math.PI / 2 }; // Platz unterhalb des Spielers
|
||||
const v = swarmVelocity(brain, { x: 800, y: 0 }, player, 100, type);
|
||||
// Ziel ist (0, encircleRadius): nach links UND nach unten.
|
||||
expect(v.x).toBeLessThan(0);
|
||||
expect(v.y).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('greift in Reichweite direkt an', () => {
|
||||
const v = swarmVelocity({ slotAngle: Math.PI / 2 }, { x: 100, y: 0 }, player, 100, type);
|
||||
expect(v.x).toBeCloseTo(-100);
|
||||
expect(v.y).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leaperStep', () => {
|
||||
const { leap } = ENEMY_TYPES.sprungkralle;
|
||||
|
||||
it('verfolgt außerhalb der Sprungreichweite', () => {
|
||||
const brain = createBrain(createRng(1), 0);
|
||||
const r = leaperStep(brain, { x: 1000, y: 0 }, player, 0, 120, leap);
|
||||
expect(r.telegraph).toBe(false);
|
||||
expect(r.velocity.x).toBeCloseTo(-120);
|
||||
});
|
||||
|
||||
it('durchläuft Ansetzen → Sprung → Abklingzeit', () => {
|
||||
const brain = createBrain(createRng(1), 0);
|
||||
const self = { x: 200, y: 0 };
|
||||
|
||||
const windup = leaperStep(brain, self, player, 0, 120, leap);
|
||||
expect(windup).toMatchObject({ telegraph: true, leaping: false, velocity: { x: 0, y: 0 } });
|
||||
|
||||
// Spieler bewegt sich während des Ansetzens: Die Sprungrichtung bleibt fest.
|
||||
const moved = { x: 0, y: 500 };
|
||||
const jump = leaperStep(brain, self, moved, leap.windupMs, 120, leap);
|
||||
expect(jump.leaping).toBe(true);
|
||||
expect(jump.velocity.x).toBeCloseTo(-leap.speed);
|
||||
expect(jump.velocity.y).toBeCloseTo(0);
|
||||
|
||||
const landed = leaperStep(brain, self, player, leap.windupMs + leap.durationMs, 120, leap);
|
||||
expect(landed.leaping).toBe(false);
|
||||
expect(landed.telegraph).toBe(false);
|
||||
|
||||
// Während der Abklingzeit kein neuer Sprung, obwohl in Reichweite.
|
||||
const cooling = leaperStep(brain, self, player, leap.windupMs + leap.durationMs + 10, 120, leap);
|
||||
expect(cooling.telegraph).toBe(false);
|
||||
const again = leaperStep(brain, self, player, leap.windupMs + leap.durationMs + leap.cooldownMs, 120, leap);
|
||||
expect(again.telegraph).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flankVelocity', () => {
|
||||
it('schwenkt seitlich ein statt frontal anzulaufen', () => {
|
||||
const type = ENEMY_TYPES.blechsoldat;
|
||||
const self = { x: 600, y: 0 };
|
||||
const left = flankVelocity({ flankSide: 1 }, self, player, 80, type);
|
||||
const right = flankVelocity({ flankSide: -1 }, self, player, 80, type);
|
||||
expect(Math.sign(left.y)).not.toBe(Math.sign(right.y));
|
||||
expect(length(left)).toBeCloseTo(80);
|
||||
});
|
||||
|
||||
it('hält an der Flankenposition Abstand zum Spieler', () => {
|
||||
const type = ENEMY_TYPES.blechsoldat;
|
||||
const angle = degToRad(type.flankAngleDeg);
|
||||
// Genau an der Flankenposition relativ zu sich selbst gibt es keinen festen Punkt,
|
||||
// daher: sehr nah am Spieler → muss zurückweichen oder seitlich ausweichen.
|
||||
const self = { x: Math.cos(angle) * 100, y: Math.sin(angle) * 100 };
|
||||
const v = flankVelocity({ flankSide: 1 }, self, player, 80, type);
|
||||
expect(distance({ x: self.x + v.x, y: self.y + v.y }, player)).toBeGreaterThanOrEqual(distance(self, player) - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStrafe', () => {
|
||||
it('wechselt die Richtung nach Ablauf des Intervalls', () => {
|
||||
const brain = { strafeDirection: 1, nextStrafeSwitchAt: 100 };
|
||||
updateStrafe(brain, 50, { min: 1000, max: 1000 }, createRng(1));
|
||||
expect(brain.strafeDirection).toBe(1);
|
||||
updateStrafe(brain, 100, { min: 1000, max: 1000 }, createRng(1));
|
||||
expect(brain.strafeDirection).toBe(-1);
|
||||
expect(brain.nextStrafeSwitchAt).toBe(1100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attackStep', () => {
|
||||
const attack = { cooldownMs: 1000, burst: 3, burstIntervalMs: 100, maxDistance: 400 };
|
||||
|
||||
it('feuert nicht außerhalb der Reichweite', () => {
|
||||
const state = createAttackState(0);
|
||||
expect(attackStep(state, 0, 401, attack)).toBe(false);
|
||||
});
|
||||
|
||||
it('feuert eine Salve mit Abständen und wartet dann die Abklingzeit ab', () => {
|
||||
const state = createAttackState(0);
|
||||
const shots = [];
|
||||
for (let t = 0; t <= 1200; t += 50) {
|
||||
if (attackStep(state, t, 100, attack)) shots.push(t);
|
||||
}
|
||||
expect(shots).toEqual([0, 100, 200, 1000, 1100, 1200]);
|
||||
});
|
||||
|
||||
it('respektiert die Verzögerung des ersten Angriffs', () => {
|
||||
const state = createAttackState(500);
|
||||
expect(attackStep(state, 499, 100, attack)).toBe(false);
|
||||
expect(attackStep(state, 500, 100, attack)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('volleyAngles', () => {
|
||||
it('liefert bei einem Projektil genau die Zielrichtung', () => {
|
||||
expect(volleyAngles(1.2, 1, 40)).toEqual([1.2]);
|
||||
});
|
||||
|
||||
it('verteilt einen Fächer symmetrisch um die Zielrichtung', () => {
|
||||
const angles = volleyAngles(0, 5, 40);
|
||||
expect(angles).toHaveLength(5);
|
||||
expect(angles[0]).toBeCloseTo(-degToRad(20));
|
||||
expect(angles[2]).toBeCloseTo(0);
|
||||
expect(angles[4]).toBeCloseTo(degToRad(20));
|
||||
});
|
||||
});
|
||||
|
||||
describe('broodStep', () => {
|
||||
const brood = { intervalMs: 1000, spawnCount: 3, maxChildren: 5 };
|
||||
|
||||
it('brütet im Takt und respektiert die Obergrenze', () => {
|
||||
const brain = { nextBroodAt: 0 };
|
||||
expect(broodStep(brain, 0, 0, brood)).toBe(3);
|
||||
expect(broodStep(brain, 500, 3, brood)).toBe(0);
|
||||
expect(broodStep(brain, 1000, 3, brood)).toBe(2);
|
||||
expect(broodStep(brain, 2000, 5, brood)).toBe(0);
|
||||
expect(broodStep(brain, 3000, 9, brood)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatCooldown,
|
||||
formatDuration,
|
||||
formatMultiplier,
|
||||
formatScore,
|
||||
formatSequence,
|
||||
sanitizeName,
|
||||
validateName,
|
||||
} from '../../app/static/js/logic/format.js';
|
||||
|
||||
describe('Formatierung', () => {
|
||||
it('formatiert Punkte mit Tausenderpunkt', () => {
|
||||
expect(formatScore(0)).toBe('0');
|
||||
expect(formatScore(1234567)).toBe('1.234.567');
|
||||
expect(formatScore(-5)).toBe('0');
|
||||
});
|
||||
|
||||
it('formatiert Dauer als m:ss', () => {
|
||||
expect(formatDuration(0)).toBe('0:00');
|
||||
expect(formatDuration(65_400)).toBe('1:05');
|
||||
expect(formatDuration(3_600_000)).toBe('60:00');
|
||||
});
|
||||
|
||||
it('formatiert Richtungsfolgen als Pfeile', () => {
|
||||
expect(formatSequence(['up', 'right', 'down', 'left'])).toBe('↑ → ↓ ←');
|
||||
expect(formatSequence(['left', 'left'], ' ')).toBe('← ←');
|
||||
expect(formatSequence(['quer'])).toBe('?');
|
||||
});
|
||||
|
||||
it('formatiert Multiplikator und Abklingzeit mit Dezimalkomma', () => {
|
||||
expect(formatMultiplier(1)).toBe('×1');
|
||||
expect(formatMultiplier(2.5)).toBe('×2,5');
|
||||
expect(formatCooldown(0)).toBe('');
|
||||
expect(formatCooldown(12_100)).toBe('13');
|
||||
expect(formatCooldown(800)).toBe('0,8');
|
||||
});
|
||||
});
|
||||
|
||||
// Dieselben Fälle wie tests/test_validation.py – Frontend und Backend müssen gleich bereinigen.
|
||||
describe('sanitizeName (gleich wie Backend)', () => {
|
||||
const ZWSP = String.fromCodePoint(0x200b);
|
||||
const RLO = String.fromCodePoint(0x202e);
|
||||
const cases = [
|
||||
['Ada', 'Ada'],
|
||||
[' Max Muster ', 'Max Muster'],
|
||||
['Jürg Größli', 'Jürg Größli'],
|
||||
['Fullwidth', 'Fullwidth'],
|
||||
['Null\u0000Byte', 'NullByte'],
|
||||
[`Zero${ZWSP}Width`, 'ZeroWidth'],
|
||||
[`Bidi${RLO}Trick`, 'BidiTrick'],
|
||||
['Tab\tNeu\nZeile', 'TabNeuZeile'],
|
||||
['<script>alert(1)</script>', 'scriptalert1script'],
|
||||
["O'Neil-Test!?", "O'Neil-Test!?"],
|
||||
['Emoji 🚀 Held', 'Emoji Held'],
|
||||
['', ''],
|
||||
];
|
||||
it.each(cases)('%j → %j', (raw, expected) => {
|
||||
expect(sanitizeName(raw)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateName', () => {
|
||||
it('akzeptiert gültige Namen bis 16 Zeichen', () => {
|
||||
expect(validateName(' Kadettin Ada ')).toEqual({ ok: true, name: 'Kadettin Ada' });
|
||||
expect(validateName('N'.repeat(16)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('lehnt leere und zu lange Namen ab', () => {
|
||||
expect(validateName(' ').ok).toBe(false);
|
||||
expect(validateName('<>').ok).toBe(false);
|
||||
expect(validateName('N'.repeat(17)).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { oncePerEvent } from '../../app/static/js/systems/keyboard.js';
|
||||
|
||||
describe('oncePerEvent', () => {
|
||||
it('lässt dasselbe Ereignisobjekt nur einmal durch', () => {
|
||||
const handler = vi.fn();
|
||||
const wrapped = oncePerEvent(handler);
|
||||
const first = { code: 'ArrowDown' };
|
||||
const second = { code: 'ArrowDown' };
|
||||
// Phaser meldet bei mehreren Ereignissen pro Frame frühere Ereignisse erneut.
|
||||
wrapped(first);
|
||||
wrapped(first);
|
||||
wrapped(second);
|
||||
wrapped(first);
|
||||
wrapped(second);
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler.mock.calls.map(([event]) => event)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('funktioniert auch ohne Ereignisobjekt (z. B. Zeiger-Aufrufe)', () => {
|
||||
const handler = vi.fn();
|
||||
const wrapped = oncePerEvent(handler);
|
||||
wrapped(undefined);
|
||||
wrapped(undefined);
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
angleTo,
|
||||
clamp,
|
||||
clampLength,
|
||||
directionFromKeys,
|
||||
distance,
|
||||
inCircle,
|
||||
length,
|
||||
normalize,
|
||||
rotate,
|
||||
} from '../../app/static/js/logic/math.js';
|
||||
import { createRng, pick, randFloat, randInt } from '../../app/static/js/logic/rng.js';
|
||||
|
||||
describe('math', () => {
|
||||
it('clamp begrenzt Werte', () => {
|
||||
expect(clamp(5, 0, 3)).toBe(3);
|
||||
expect(clamp(-1, 0, 3)).toBe(0);
|
||||
expect(clamp(2, 0, 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('normalize lässt den Nullvektor unverändert', () => {
|
||||
expect(normalize({ x: 0, y: 0 })).toEqual({ x: 0, y: 0 });
|
||||
expect(length(normalize({ x: 3, y: 4 }))).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it('clampLength kürzt nur zu lange Vektoren', () => {
|
||||
expect(length(clampLength({ x: 30, y: 40 }, 10))).toBeCloseTo(10);
|
||||
expect(clampLength({ x: 1, y: 1 }, 10)).toEqual({ x: 1, y: 1 });
|
||||
});
|
||||
|
||||
it('rotate und angleTo passen zusammen', () => {
|
||||
const r = rotate({ x: 1, y: 0 }, Math.PI / 2);
|
||||
expect(r.x).toBeCloseTo(0);
|
||||
expect(r.y).toBeCloseTo(1);
|
||||
expect(angleTo({ x: 0, y: 0 }, { x: 0, y: 5 })).toBeCloseTo(Math.PI / 2);
|
||||
});
|
||||
|
||||
it('inCircle berücksichtigt die Ausdehnung des Objekts', () => {
|
||||
expect(inCircle({ x: 110, y: 0 }, { x: 0, y: 0 }, 100)).toBe(false);
|
||||
expect(inCircle({ x: 110, y: 0 }, { x: 0, y: 0 }, 100, 12)).toBe(true);
|
||||
expect(distance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5);
|
||||
});
|
||||
|
||||
it('directionFromKeys normalisiert Diagonalen (8-Wege-Bewegung)', () => {
|
||||
expect(directionFromKeys({ up: true, down: false, left: false, right: false })).toEqual({ x: 0, y: -1 });
|
||||
const diag = directionFromKeys({ up: true, down: false, left: false, right: true });
|
||||
expect(length(diag)).toBeCloseTo(1);
|
||||
expect(diag.x).toBeGreaterThan(0);
|
||||
expect(diag.y).toBeLessThan(0);
|
||||
expect(directionFromKeys({ up: true, down: true, left: true, right: true })).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rng', () => {
|
||||
it('ist mit gleichem Seed deterministisch und liegt in [0, 1)', () => {
|
||||
const a = createRng(123);
|
||||
const b = createRng(123);
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const value = a();
|
||||
expect(value).toBe(b());
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('randInt deckt die Grenzen ab und bleibt im Intervall', () => {
|
||||
const rng = createRng(7);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
const n = randInt(rng, 1, 3);
|
||||
expect(n).toBeGreaterThanOrEqual(1);
|
||||
expect(n).toBeLessThanOrEqual(3);
|
||||
seen.add(n);
|
||||
}
|
||||
expect(seen).toEqual(new Set([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('randFloat und pick verhalten sich korrekt', () => {
|
||||
const rng = createRng(9);
|
||||
const f = randFloat(rng, 5, 6);
|
||||
expect(f).toBeGreaterThanOrEqual(5);
|
||||
expect(f).toBeLessThan(6);
|
||||
expect(['a', 'b']).toContain(pick(rng, ['a', 'b']));
|
||||
expect(() => pick(rng, [])).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SCORING } from '../../app/static/js/config.js';
|
||||
import { multiplierForStreak, ScoreKeeper, waveBonus } from '../../app/static/js/logic/scoring.js';
|
||||
|
||||
describe('multiplierForStreak', () => {
|
||||
it('steigt alle stepKills Abschüsse um stepValue', () => {
|
||||
expect(multiplierForStreak(0)).toBe(1);
|
||||
expect(multiplierForStreak(1)).toBe(1);
|
||||
expect(multiplierForStreak(SCORING.stepKills - 1)).toBe(1);
|
||||
expect(multiplierForStreak(SCORING.stepKills)).toBe(1 + SCORING.stepValue);
|
||||
expect(multiplierForStreak(SCORING.stepKills * 2)).toBe(1 + 2 * SCORING.stepValue);
|
||||
});
|
||||
|
||||
it('ist nach oben gedeckelt', () => {
|
||||
expect(multiplierForStreak(10_000)).toBe(SCORING.maxMultiplier);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waveBonus', () => {
|
||||
it('wächst linear mit der Welle', () => {
|
||||
expect(waveBonus(1)).toBe(SCORING.waveBonusPerWave);
|
||||
expect(waveBonus(7)).toBe(7 * SCORING.waveBonusPerWave);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScoreKeeper', () => {
|
||||
const cfg = { ...SCORING, streakWindowMs: 1000, stepKills: 2, stepValue: 1, maxMultiplier: 3 };
|
||||
|
||||
it('zählt Abschüsse ohne Serie mit Faktor 1', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
expect(keeper.registerKill(10, 0)).toEqual({ awarded: 10, multiplier: 1, streak: 1 });
|
||||
expect(keeper.registerKill(10, 5000)).toEqual({ awarded: 10, multiplier: 1, streak: 1 });
|
||||
expect(keeper.score).toBe(20);
|
||||
expect(keeper.kills).toBe(2);
|
||||
});
|
||||
|
||||
it('baut innerhalb des Fensters eine Serie mit Multiplikator auf', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
const results = [0, 500, 900, 1400, 1800].map((t) => keeper.registerKill(10, t));
|
||||
expect(results.map((r) => r.multiplier)).toEqual([1, 2, 2, 3, 3]);
|
||||
expect(keeper.score).toBe(10 + 20 + 20 + 30 + 30);
|
||||
expect(keeper.bestStreak).toBe(5);
|
||||
});
|
||||
|
||||
it('lässt die Serie nach Ablauf des Fensters verfallen', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
keeper.registerKill(10, 0);
|
||||
keeper.registerKill(10, 100);
|
||||
expect(keeper.multiplierAt(500)).toBe(2);
|
||||
expect(keeper.multiplierAt(1200)).toBe(1);
|
||||
expect(keeper.registerKill(10, 1200).streak).toBe(1);
|
||||
expect(keeper.bestStreak).toBe(2);
|
||||
});
|
||||
|
||||
it('beendet die Serie bei Treffern am Präsidenten', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
keeper.registerKill(10, 0);
|
||||
keeper.registerKill(10, 100);
|
||||
keeper.breakStreak();
|
||||
expect(keeper.multiplierAt(200)).toBe(1);
|
||||
expect(keeper.registerKill(10, 200)).toEqual({ awarded: 10, multiplier: 1, streak: 1 });
|
||||
});
|
||||
|
||||
it('vergibt Bonuspunkte ohne Multiplikator und ignoriert negative Werte', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
keeper.registerKill(10, 0);
|
||||
keeper.registerKill(10, 10);
|
||||
expect(keeper.addBonus(100)).toBe(100);
|
||||
expect(keeper.addBonus(-50)).toBe(0);
|
||||
expect(keeper.score).toBe(10 + 20 + 100);
|
||||
});
|
||||
|
||||
it('liefert eine Momentaufnahme mit Restzeit der Serie', () => {
|
||||
const keeper = new ScoreKeeper(cfg);
|
||||
keeper.registerKill(10, 0);
|
||||
const snap = keeper.snapshot(250);
|
||||
expect(snap).toMatchObject({ score: 10, kills: 1, streak: 1, multiplier: 1, bestStreak: 1 });
|
||||
expect(snap.streakTimeLeft).toBeCloseTo(0.75);
|
||||
expect(keeper.snapshot(5000)).toMatchObject({ streak: 0, streakTimeLeft: 0 });
|
||||
});
|
||||
|
||||
it('rundet Punkte bei gebrochenen Multiplikatoren', () => {
|
||||
const keeper = new ScoreKeeper({ ...cfg, stepValue: 0.5 });
|
||||
keeper.registerKill(7, 0);
|
||||
expect(keeper.registerKill(7, 10).awarded).toBe(Math.round(7 * 1.5));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AUDIO, STORAGE_KEYS } from '../../app/static/js/config.js';
|
||||
import { SafeStorage, Settings } from '../../app/static/js/systems/Settings.js';
|
||||
|
||||
/** Einfacher localStorage-Ersatz. */
|
||||
function memoryBackend(initial = {}) {
|
||||
const data = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key) => (data.has(key) ? data.get(key) : null),
|
||||
setItem: (key, value) => data.set(key, String(value)),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/** localStorage, der jeden Zugriff verweigert (z. B. blockierte Website-Daten). */
|
||||
const throwingBackend = {
|
||||
getItem: () => {
|
||||
throw new Error('SecurityError');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('QuotaExceededError');
|
||||
},
|
||||
};
|
||||
|
||||
describe('SafeStorage', () => {
|
||||
it('speichert JSON-Werte', () => {
|
||||
const storage = new SafeStorage(memoryBackend());
|
||||
expect(storage.set('a', { x: 1 })).toBe(true);
|
||||
expect(storage.get('a', null)).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
it('liefert den Standardwert bei fehlenden oder kaputten Einträgen', () => {
|
||||
const storage = new SafeStorage(memoryBackend({ kaputt: '{nicht json' }));
|
||||
expect(storage.get('fehlt', 42)).toBe(42);
|
||||
expect(storage.get('kaputt', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('funktioniert ohne localStorage für die laufende Sitzung weiter', () => {
|
||||
for (const backend of [null, throwingBackend]) {
|
||||
const storage = new SafeStorage(backend);
|
||||
expect(storage.set('k', 'v')).toBe(false);
|
||||
expect(storage.get('k', null)).toBe('v');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings', () => {
|
||||
it('merkt sich die Disclaimer-Bestätigung', () => {
|
||||
const backend = memoryBackend();
|
||||
const settings = new Settings(new SafeStorage(backend));
|
||||
expect(settings.isDisclaimerAccepted()).toBe(false);
|
||||
settings.acceptDisclaimer();
|
||||
expect(new Settings(new SafeStorage(backend)).isDisclaimerAccepted()).toBe(true);
|
||||
expect(backend.data.get(STORAGE_KEYS.disclaimerAccepted)).toBe('true');
|
||||
});
|
||||
|
||||
it('startet mit Standardlautstärken und begrenzt Werte auf 0..1', () => {
|
||||
const settings = new Settings(new SafeStorage(memoryBackend()));
|
||||
expect(settings.getVolume('music')).toBe(AUDIO.defaultVolumes.music);
|
||||
settings.setVolume('music', 5);
|
||||
expect(settings.getVolume('music')).toBe(1);
|
||||
settings.setVolume('sfx', -1);
|
||||
expect(settings.getVolume('sfx')).toBe(0);
|
||||
settings.setVolume('sfx', Number.NaN);
|
||||
expect(settings.getVolume('sfx')).toBe(0);
|
||||
});
|
||||
|
||||
it('ignoriert ungültige gespeicherte Lautstärken', () => {
|
||||
const backend = memoryBackend({ [STORAGE_KEYS.volumes]: JSON.stringify({ master: 'laut', music: 0.25, sfx: 9 }) });
|
||||
const settings = new Settings(new SafeStorage(backend));
|
||||
expect(settings.getVolume('master')).toBe(AUDIO.defaultVolumes.master);
|
||||
expect(settings.getVolume('music')).toBe(0.25);
|
||||
expect(settings.getVolume('sfx')).toBe(1);
|
||||
});
|
||||
|
||||
it('berechnet die effektive Lautstärke inklusive Gesamtregler', () => {
|
||||
const settings = new Settings(new SafeStorage(memoryBackend()));
|
||||
settings.setVolume('master', 0.5);
|
||||
settings.setVolume('sfx', 0.5);
|
||||
expect(settings.effectiveVolume('sfx')).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it('benachrichtigt Zuhörer bei Lautstärkeänderungen', () => {
|
||||
const settings = new Settings(new SafeStorage(memoryBackend()));
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = settings.onVolumeChange(listener);
|
||||
settings.setVolume('master', 0.3);
|
||||
expect(listener).toHaveBeenCalledWith('master', 0.3);
|
||||
unsubscribe();
|
||||
settings.setVolume('master', 0.4);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('speichert den letzten Namen gekürzt', () => {
|
||||
const settings = new Settings(new SafeStorage(memoryBackend()));
|
||||
settings.setLastName('A'.repeat(40));
|
||||
expect(settings.getLastName()).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { distance, length } from '../../app/static/js/logic/math.js';
|
||||
import {
|
||||
combine,
|
||||
encirclePoint,
|
||||
flankPoint,
|
||||
flee,
|
||||
keepDistance,
|
||||
seek,
|
||||
separation,
|
||||
steerTowards,
|
||||
} from '../../app/static/js/logic/steering.js';
|
||||
|
||||
const close = (actual, expected) => {
|
||||
expect(actual.x).toBeCloseTo(expected.x);
|
||||
expect(actual.y).toBeCloseTo(expected.y);
|
||||
};
|
||||
|
||||
describe('seek / flee', () => {
|
||||
it('bewegt sich mit voller Geschwindigkeit zum Ziel bzw. davon weg', () => {
|
||||
close(seek({ x: 0, y: 0 }, { x: 10, y: 0 }, 5), { x: 5, y: 0 });
|
||||
close(flee({ x: 0, y: 0 }, { x: 0, y: 10 }, 3), { x: 0, y: -3 });
|
||||
});
|
||||
|
||||
it('steht still, wenn es bereits am Ziel ist', () => {
|
||||
expect(seek({ x: 4, y: 4 }, { x: 4, y: 4 }, 100)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('separation', () => {
|
||||
it('stößt nahe Nachbarn ab und ignoriert entfernte', () => {
|
||||
const self = { x: 0, y: 0 };
|
||||
const push = separation(self, [self, { x: 10, y: 0 }, { x: 500, y: 0 }], 20);
|
||||
expect(push.x).toBeLessThan(0);
|
||||
expect(push.y).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('trennt exakt übereinanderliegende Gegner ohne NaN', () => {
|
||||
const push = separation({ x: 5, y: 5 }, [{ x: 5, y: 5 }], 20);
|
||||
expect(Number.isFinite(push.x) && Number.isFinite(push.y)).toBe(true);
|
||||
expect(length(push)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('drückt stärker, je näher der Nachbar ist', () => {
|
||||
const near = separation({ x: 0, y: 0 }, [{ x: 2, y: 0 }], 20);
|
||||
const far = separation({ x: 0, y: 0 }, [{ x: 18, y: 0 }], 20);
|
||||
expect(length(near)).toBeGreaterThan(length(far));
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepDistance', () => {
|
||||
const band = { min: 200, max: 300 };
|
||||
const target = { x: 0, y: 0 };
|
||||
|
||||
it('nähert sich an, wenn es zu weit weg ist', () => {
|
||||
const v = keepDistance({ x: 500, y: 0 }, target, band, 10, 1);
|
||||
close(v, { x: -10, y: 0 });
|
||||
});
|
||||
|
||||
it('weicht zurück, wenn es zu nah ist', () => {
|
||||
const v = keepDistance({ x: 100, y: 0 }, target, band, 10, 1);
|
||||
close(v, { x: 10, y: 0 });
|
||||
});
|
||||
|
||||
it('weicht im Wohlfühlbereich seitlich aus (senkrecht zur Linie)', () => {
|
||||
const cw = keepDistance({ x: 250, y: 0 }, target, band, 10, 1, 0.5);
|
||||
const ccw = keepDistance({ x: 250, y: 0 }, target, band, 10, -1, 0.5);
|
||||
expect(cw.x).toBeCloseTo(0);
|
||||
expect(Math.abs(cw.y)).toBeCloseTo(5);
|
||||
expect(ccw.y).toBeCloseTo(-cw.y);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flankPoint / encirclePoint', () => {
|
||||
it('liegt im gewünschten Abstand seitlich versetzt', () => {
|
||||
const target = { x: 0, y: 0 };
|
||||
const enemy = { x: 100, y: 0 };
|
||||
const p = flankPoint(target, enemy, Math.PI / 2, 50);
|
||||
expect(distance(p, target)).toBeCloseTo(50);
|
||||
close(p, { x: 0, y: 50 });
|
||||
const other = flankPoint(target, enemy, -Math.PI / 2, 50);
|
||||
close(other, { x: 0, y: -50 });
|
||||
});
|
||||
|
||||
it('verteilt Umzingelungsplätze auf einem Kreis', () => {
|
||||
const target = { x: 10, y: 10 };
|
||||
close(encirclePoint(target, 0, 100), { x: 110, y: 10 });
|
||||
close(encirclePoint(target, Math.PI, 100), { x: -90, y: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('combine / steerTowards', () => {
|
||||
it('gewichtet Anteile und begrenzt auf die Höchstgeschwindigkeit', () => {
|
||||
const v = combine(
|
||||
[
|
||||
{ vector: { x: 10, y: 0 }, weight: 1 },
|
||||
{ vector: { x: 0, y: 10 }, weight: 2 },
|
||||
],
|
||||
15,
|
||||
);
|
||||
expect(length(v)).toBeCloseTo(15);
|
||||
expect(v.y).toBeGreaterThan(v.x);
|
||||
});
|
||||
|
||||
it('nähert sich der Wunschgeschwindigkeit schrittweise an', () => {
|
||||
close(steerTowards({ x: 0, y: 0 }, { x: 10, y: -10 }, 0.25), { x: 2.5, y: -2.5 });
|
||||
close(steerTowards({ x: 3, y: 3 }, { x: 10, y: -10 }, 1), { x: 10, y: -10 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { WAVES } from '../../app/static/js/config.js';
|
||||
import { createRng } from '../../app/static/js/logic/rng.js';
|
||||
import { WaveDirector } from '../../app/static/js/logic/waveDirector.js';
|
||||
|
||||
/** Minimaler Spielersatz: zählt gespawnte Gegner und lässt sie auf Befehl sterben. */
|
||||
function harness(cfgOverrides = {}) {
|
||||
const log = { spawned: 0, started: [], cleared: [] };
|
||||
let alive = 0;
|
||||
const director = new WaveDirector({
|
||||
rng: createRng(1),
|
||||
cfg: { ...WAVES, ...cfgOverrides },
|
||||
spawnGroup: (group) => {
|
||||
log.spawned += group.count;
|
||||
alive += group.count;
|
||||
},
|
||||
aliveCount: () => alive,
|
||||
onWaveStart: (plan) => log.started.push(plan.number),
|
||||
onWaveCleared: (n) => log.cleared.push(n),
|
||||
});
|
||||
return {
|
||||
director,
|
||||
log,
|
||||
killAll: () => {
|
||||
alive = 0;
|
||||
},
|
||||
get alive() {
|
||||
return alive;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Lässt die Zeit in 50-ms-Schritten laufen. */
|
||||
function run(director, from, until, step = 50) {
|
||||
let now = from;
|
||||
while (now < until) {
|
||||
now += step;
|
||||
director.update(now, step);
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
||||
describe('WaveDirector', () => {
|
||||
it('startet die erste Welle nach der Startverzögerung', () => {
|
||||
const { director, log } = harness();
|
||||
director.start(0);
|
||||
expect(director.state).toBe('intermission');
|
||||
run(director, 0, WAVES.firstWaveDelayMs - 50);
|
||||
expect(log.started).toEqual([]);
|
||||
run(director, WAVES.firstWaveDelayMs - 50, WAVES.firstWaveDelayMs);
|
||||
expect(log.started).toEqual([1]);
|
||||
expect(director.state).toBe('active');
|
||||
});
|
||||
|
||||
it('spawnt alle Gruppen der Welle nach Plan', () => {
|
||||
const { director, log } = harness();
|
||||
director.start(0);
|
||||
const now = run(director, 0, WAVES.firstWaveDelayMs);
|
||||
const planned = director.plan.totalEnemies;
|
||||
run(director, now, now + 60_000);
|
||||
expect(log.spawned).toBe(planned);
|
||||
expect(director.pendingEnemies).toBe(0);
|
||||
});
|
||||
|
||||
it('meldet eine Welle erst als geschafft, wenn alle Gegner besiegt sind', () => {
|
||||
const h = harness();
|
||||
h.director.start(0);
|
||||
let now = run(h.director, 0, 60_000);
|
||||
expect(h.log.cleared).toEqual([]);
|
||||
h.killAll();
|
||||
now = run(h.director, now, now + 100);
|
||||
expect(h.log.cleared).toEqual([1]);
|
||||
expect(h.director.state).toBe('intermission');
|
||||
expect(h.director.intermissionRemaining(now)).toBeGreaterThan(WAVES.intermissionMs - 200);
|
||||
});
|
||||
|
||||
it('startet nach der Pause die nächste, größere Welle', () => {
|
||||
const h = harness();
|
||||
h.director.start(0);
|
||||
let now = run(h.director, 0, 60_000);
|
||||
const firstSize = h.director.plan.totalEnemies;
|
||||
h.killAll();
|
||||
now = run(h.director, now, now + WAVES.intermissionMs + 200);
|
||||
expect(h.log.started).toEqual([1, 2]);
|
||||
run(h.director, now, now + 60_000);
|
||||
expect(h.director.plan.totalEnemies).toBeGreaterThan(firstSize);
|
||||
});
|
||||
|
||||
it('hält den Spawn-Ablauf an, solange zu viele Gegner leben', () => {
|
||||
const h = harness({ maxAliveEnemies: 3 });
|
||||
h.director.start(0);
|
||||
// Wellenstart plus ein Frame: Die erste Gruppe (≥4 Krabbler) überschreitet das Limit sofort.
|
||||
let now = run(h.director, 0, WAVES.firstWaveDelayMs + 50);
|
||||
const spawnedBefore = h.log.spawned;
|
||||
expect(h.alive).toBeGreaterThanOrEqual(3);
|
||||
now = run(h.director, now, now + 60_000);
|
||||
expect(h.log.spawned).toBe(spawnedBefore);
|
||||
h.killAll();
|
||||
run(h.director, now, now + 60_000);
|
||||
expect(h.log.spawned).toBeGreaterThan(spawnedBefore);
|
||||
});
|
||||
|
||||
it('tut vor dem Start nichts', () => {
|
||||
const { director, log } = harness();
|
||||
run(director, 0, 10_000);
|
||||
expect(log.started).toEqual([]);
|
||||
expect(director.pendingEnemies).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ENEMY_TYPES, WAVES } from '../../app/static/js/config.js';
|
||||
import { createRng } from '../../app/static/js/logic/rng.js';
|
||||
import {
|
||||
EDGES,
|
||||
eliteCount,
|
||||
generateWave,
|
||||
spawnPointOnEdge,
|
||||
waveBudget,
|
||||
waveFactions,
|
||||
waveMultipliers,
|
||||
} from '../../app/static/js/logic/waves.js';
|
||||
|
||||
const cost = (plan) => plan.groups.reduce((sum, g) => sum + g.count * ENEMY_TYPES[g.type].cost, 0);
|
||||
const factionsIn = (plan) => new Set(plan.groups.map((g) => ENEMY_TYPES[g.type].faction));
|
||||
const tiersIn = (plan) => plan.groups.map((g) => ENEMY_TYPES[g.type].tier);
|
||||
|
||||
describe('waveBudget', () => {
|
||||
it('beginnt mit dem Startbudget und wächst streng monoton', () => {
|
||||
expect(waveBudget(1)).toBe(WAVES.firstBudget);
|
||||
for (let n = 1; n < 50; n += 1) {
|
||||
expect(waveBudget(n + 1)).toBeGreaterThan(waveBudget(n));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('waveFactions', () => {
|
||||
it('führt die Fraktionen nacheinander ein', () => {
|
||||
expect(waveFactions(1)).toEqual(['swarm']);
|
||||
expect(waveFactions(2)).toEqual(['swarm']);
|
||||
expect(waveFactions(3)).toEqual(['legion']);
|
||||
expect(waveFactions(4)).toEqual(['legion']);
|
||||
expect(waveFactions(5)).toEqual(['swarm', 'legion']);
|
||||
expect(waveFactions(30)).toEqual(['swarm', 'legion']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eliteCount', () => {
|
||||
it('bringt Eliten nur in jeder n-ten Welle und begrenzt ihre Zahl', () => {
|
||||
expect(eliteCount(4)).toBe(0);
|
||||
expect(eliteCount(5)).toBe(1);
|
||||
expect(eliteCount(7)).toBe(0);
|
||||
expect(eliteCount(10)).toBe(2);
|
||||
expect(eliteCount(WAVES.eliteEvery * 50)).toBe(WAVES.maxElitesPerWave);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waveMultipliers', () => {
|
||||
it('lässt frühe Wellen unverändert', () => {
|
||||
expect(waveMultipliers(1)).toEqual({ hp: 1, speed: 1 });
|
||||
expect(waveMultipliers(WAVES.scalingFromWave - 1)).toEqual({ hp: 1, speed: 1 });
|
||||
});
|
||||
|
||||
it('macht spätere Wellen zäher, deckelt aber das Tempo', () => {
|
||||
expect(waveMultipliers(WAVES.scalingFromWave).hp).toBeGreaterThan(1);
|
||||
expect(waveMultipliers(20).hp).toBeGreaterThan(waveMultipliers(10).hp);
|
||||
expect(waveMultipliers(500).speed).toBe(WAVES.speedScaleMax);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateWave', () => {
|
||||
it('ist mit gleichem Seed reproduzierbar', () => {
|
||||
expect(generateWave(7, createRng(42))).toEqual(generateWave(7, createRng(42)));
|
||||
});
|
||||
|
||||
it('liefert mit anderem Seed eine andere Welle', () => {
|
||||
expect(generateWave(7, createRng(1))).not.toEqual(generateWave(7, createRng(2)));
|
||||
});
|
||||
|
||||
it('wirft bei ungültiger Wellennummer', () => {
|
||||
expect(() => generateWave(0, createRng(1))).toThrow(RangeError);
|
||||
expect(() => generateWave(1.5, createRng(1))).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('schickt in Welle 1–2 nur Schwärmer und in Welle 3–4 nur die Blechlegion', () => {
|
||||
for (let seed = 0; seed < 20; seed += 1) {
|
||||
expect(factionsIn(generateWave(1, createRng(seed)))).toEqual(new Set(['swarm']));
|
||||
expect(factionsIn(generateWave(2, createRng(seed)))).toEqual(new Set(['swarm']));
|
||||
expect(factionsIn(generateWave(3, createRng(seed)))).toEqual(new Set(['legion']));
|
||||
expect(factionsIn(generateWave(4, createRng(seed)))).toEqual(new Set(['legion']));
|
||||
}
|
||||
});
|
||||
|
||||
it('mischt ab Welle 6 beide Fraktionen', () => {
|
||||
const seen = new Set();
|
||||
for (let seed = 0; seed < 20; seed += 1) {
|
||||
for (const faction of factionsIn(generateWave(6, createRng(seed)))) seen.add(faction);
|
||||
}
|
||||
expect(seen).toEqual(new Set(['swarm', 'legion']));
|
||||
});
|
||||
|
||||
it('enthält Eliten genau in Elite-Wellen', () => {
|
||||
for (let n = 1; n <= 20; n += 1) {
|
||||
const plan = generateWave(n, createRng(n));
|
||||
const elites = tiersIn(plan).filter((t) => t === 'elite').length;
|
||||
expect(elites, `Welle ${n}`).toBe(eliteCount(n));
|
||||
}
|
||||
});
|
||||
|
||||
it('wechselt die Elite-Fraktion zwischen Elite-Wellen', () => {
|
||||
const eliteFaction = (n) =>
|
||||
generateWave(n, createRng(3))
|
||||
.groups.map((g) => ENEMY_TYPES[g.type])
|
||||
.find((t) => t.tier === 'elite').faction;
|
||||
expect(eliteFaction(5)).toBe('swarm');
|
||||
expect(eliteFaction(10)).toBe('legion');
|
||||
});
|
||||
|
||||
it('schöpft das Budget in Wellen ohne Eliten fast vollständig aus', () => {
|
||||
const cheapest = Math.min(...Object.values(ENEMY_TYPES).map((t) => t.cost));
|
||||
for (const n of [1, 2, 3, 4, 6, 7, 8, 13]) {
|
||||
for (let seed = 0; seed < 10; seed += 1) {
|
||||
const plan = generateWave(n, createRng(seed));
|
||||
expect(cost(plan)).toBeLessThanOrEqual(plan.budget);
|
||||
// Rest kleiner als der teuerste Kleingegner einer Fraktion (Legion: 2).
|
||||
expect(plan.budget - cost(plan)).toBeLessThan(cheapest + 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('hält die Gruppengrößen der Gegnertypen ein', () => {
|
||||
for (let n = 1; n <= 25; n += 1) {
|
||||
for (const group of generateWave(n, createRng(n * 7)).groups) {
|
||||
const { groupSize } = ENEMY_TYPES[group.type];
|
||||
expect(group.count).toBeGreaterThanOrEqual(1);
|
||||
expect(group.count).toBeLessThanOrEqual(groupSize.max);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('plant Spawns zeitlich aufsteigend ab 0 ms und an gültigen Rändern', () => {
|
||||
const plan = generateWave(12, createRng(99));
|
||||
expect(plan.groups[0].atMs).toBe(0);
|
||||
for (let i = 1; i < plan.groups.length; i += 1) {
|
||||
expect(plan.groups[i].atMs).toBeGreaterThanOrEqual(plan.groups[i - 1].atMs);
|
||||
}
|
||||
for (const group of plan.groups) expect(EDGES).toContain(group.edge);
|
||||
});
|
||||
|
||||
it('zählt die Gegner korrekt und wird mit der Zeit größer', () => {
|
||||
const early = generateWave(2, createRng(5));
|
||||
const late = generateWave(15, createRng(5));
|
||||
expect(early.totalEnemies).toBe(early.groups.reduce((s, g) => s + g.count, 0));
|
||||
expect(late.totalEnemies).toBeGreaterThan(early.totalEnemies);
|
||||
});
|
||||
|
||||
it('setzt mehr mittlere Gegner in späten Wellen ein', () => {
|
||||
const mediumShare = (n) => {
|
||||
let medium = 0;
|
||||
let total = 0;
|
||||
for (let seed = 0; seed < 30; seed += 1) {
|
||||
const plan = generateWave(n, createRng(seed));
|
||||
for (const g of plan.groups) {
|
||||
const t = ENEMY_TYPES[g.type];
|
||||
if (t.tier === 'medium') medium += g.count * t.cost;
|
||||
total += g.count * t.cost;
|
||||
}
|
||||
}
|
||||
return medium / total;
|
||||
};
|
||||
expect(mediumShare(12)).toBeGreaterThan(mediumShare(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnPointOnEdge', () => {
|
||||
const world = { width: 2000, height: 1000 };
|
||||
|
||||
it('setzt Punkte an den richtigen Rand innerhalb der Welt', () => {
|
||||
const rng = createRng(1);
|
||||
expect(spawnPointOnEdge('north', world, 40, rng).y).toBe(40);
|
||||
expect(spawnPointOnEdge('south', world, 40, rng).y).toBe(960);
|
||||
expect(spawnPointOnEdge('west', world, 40, rng).x).toBe(40);
|
||||
expect(spawnPointOnEdge('east', world, 40, rng).x).toBe(1960);
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const p = spawnPointOnEdge('north', world, 40, rng);
|
||||
expect(p.x).toBeGreaterThanOrEqual(40);
|
||||
expect(p.x).toBeLessThanOrEqual(1960);
|
||||
}
|
||||
});
|
||||
|
||||
it('wirft bei unbekanntem Rand', () => {
|
||||
expect(() => spawnPointOnEdge('oben', world, 40, createRng(1))).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { WEAPONS } from '../../app/static/js/config.js';
|
||||
import { degToRad } from '../../app/static/js/logic/math.js';
|
||||
import { createRng } from '../../app/static/js/logic/rng.js';
|
||||
import { pelletOffsets, WeaponState } from '../../app/static/js/logic/weapon.js';
|
||||
|
||||
const def = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
magazineSize: 3,
|
||||
reserveStart: 4,
|
||||
reserveMax: 10,
|
||||
fireIntervalMs: 100,
|
||||
reloadMs: 500,
|
||||
damage: 1,
|
||||
pellets: 1,
|
||||
spreadDeg: 0,
|
||||
bulletSpeed: 1,
|
||||
rangePx: 1,
|
||||
automatic: true,
|
||||
};
|
||||
const rng = createRng(1);
|
||||
|
||||
describe('WeaponState', () => {
|
||||
it('startet mit vollem Magazin und Startreserve', () => {
|
||||
const w = new WeaponState(def);
|
||||
expect(w.magazine).toBe(3);
|
||||
expect(w.reserve).toBe(4);
|
||||
expect(w.isReloading).toBe(false);
|
||||
});
|
||||
|
||||
it('respektiert die Feuerrate', () => {
|
||||
const w = new WeaponState(def);
|
||||
expect(w.fire(0, rng)).toHaveLength(1);
|
||||
expect(w.fire(50, rng)).toBeNull();
|
||||
expect(w.fire(100, rng)).toHaveLength(1);
|
||||
expect(w.magazine).toBe(1);
|
||||
});
|
||||
|
||||
it('lädt bei leerem Magazin automatisch nach und kann währenddessen nicht feuern', () => {
|
||||
const w = new WeaponState(def);
|
||||
w.fire(0, rng);
|
||||
w.fire(100, rng);
|
||||
w.fire(200, rng);
|
||||
expect(w.magazine).toBe(0);
|
||||
expect(w.isReloading).toBe(true);
|
||||
expect(w.fire(300, rng)).toBeNull();
|
||||
expect(w.update(699)).toBe(false);
|
||||
expect(w.update(700)).toBe(true);
|
||||
expect(w.magazine).toBe(3);
|
||||
expect(w.reserve).toBe(1);
|
||||
});
|
||||
|
||||
it('lädt bei knapper Reserve nur teilweise nach und ist danach leer', () => {
|
||||
const w = new WeaponState({ ...def, reserveStart: 1 });
|
||||
w.fire(0, rng);
|
||||
w.fire(100, rng);
|
||||
w.fire(200, rng);
|
||||
w.update(10_000);
|
||||
expect(w.magazine).toBe(1);
|
||||
expect(w.reserve).toBe(0);
|
||||
w.fire(10_100, rng);
|
||||
expect(w.isEmpty).toBe(true);
|
||||
expect(w.startReload(10_200)).toBe(false);
|
||||
});
|
||||
|
||||
it('lädt nicht nach, wenn das Magazin voll ist', () => {
|
||||
const w = new WeaponState(def);
|
||||
expect(w.startReload(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('bricht das Nachladen beim Waffenwechsel ohne Verlust ab', () => {
|
||||
const w = new WeaponState(def);
|
||||
w.fire(0, rng);
|
||||
expect(w.startReload(100)).toBe(true);
|
||||
w.cancelReload();
|
||||
w.update(10_000);
|
||||
expect(w.magazine).toBe(2);
|
||||
expect(w.reserve).toBe(4);
|
||||
});
|
||||
|
||||
it('zeigt den Nachladefortschritt', () => {
|
||||
const w = new WeaponState(def);
|
||||
w.fire(0, rng);
|
||||
w.startReload(1000);
|
||||
expect(w.reloadProgress(1250)).toBeCloseTo(0.5);
|
||||
w.update(1500);
|
||||
expect(w.reloadProgress(1600)).toBe(1);
|
||||
});
|
||||
|
||||
it('füllt die Reserve per Nachschub auf', () => {
|
||||
const w = new WeaponState(def);
|
||||
w.refill();
|
||||
expect(w.reserve).toBe(10);
|
||||
});
|
||||
|
||||
it('hat bei der Dienstpistole unbegrenzte Reserve', () => {
|
||||
const pistol = new WeaponState(WEAPONS.pistol);
|
||||
expect(pistol.hasInfiniteReserve).toBe(true);
|
||||
let t = 0;
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
t += 10_000;
|
||||
pistol.update(t);
|
||||
pistol.fire(t, rng);
|
||||
}
|
||||
expect(pistol.isEmpty).toBe(false);
|
||||
pistol.refill();
|
||||
expect(pistol.reserve).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pelletOffsets', () => {
|
||||
it('streut ein Einzelprojektil innerhalb des Fächers', () => {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const [offset] = pelletOffsets(1, 10, rng);
|
||||
expect(Math.abs(offset)).toBeLessThanOrEqual(degToRad(5));
|
||||
}
|
||||
});
|
||||
|
||||
it('verteilt Schrot über den ganzen Fächer', () => {
|
||||
const offsets = pelletOffsets(8, 24, rng);
|
||||
expect(offsets).toHaveLength(8);
|
||||
const spread = degToRad(24);
|
||||
expect(Math.min(...offsets)).toBeLessThan(-spread / 3);
|
||||
expect(Math.max(...offsets)).toBeGreaterThan(spread / 3);
|
||||
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(spread / 2 + spread / 7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Tests für die Highscore-API: Lesen, Einreichen, Validierung, Tokens, Rate-Limit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from app.models import get_db, insert_score
|
||||
|
||||
|
||||
def _seed(app: Flask, entries: list[tuple[str, int]]) -> None:
|
||||
with app.app_context():
|
||||
db = get_db()
|
||||
for name, score in entries:
|
||||
insert_score(db, name, score, wave=1, duration_s=60)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ GET /api/highscores
|
||||
|
||||
|
||||
def test_highscores_empty(client: FlaskClient) -> None:
|
||||
response = client.get("/api/highscores")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"highscores": []}
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
|
||||
|
||||
def test_highscores_sorted_and_limited_to_top_10(app: Flask, client: FlaskClient) -> None:
|
||||
_seed(app, [(f"Spieler {i}", i * 100) for i in range(15)])
|
||||
scores = client.get("/api/highscores").get_json()["highscores"]
|
||||
assert len(scores) == 10
|
||||
assert [entry["score"] for entry in scores] == [1400 - i * 100 for i in range(10)]
|
||||
assert [entry["rank"] for entry in scores] == list(range(1, 11))
|
||||
assert set(scores[0]) == {"rank", "name", "score", "wave", "created_at"}
|
||||
|
||||
|
||||
def test_highscore_ties_keep_older_entry_first(app: Flask, client: FlaskClient) -> None:
|
||||
_seed(app, [("Erster", 500), ("Zweiter", 500)])
|
||||
names = [entry["name"] for entry in client.get("/api/highscores").get_json()["highscores"]]
|
||||
assert names == ["Erster", "Zweiter"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- POST /api/runs
|
||||
|
||||
|
||||
def test_start_run_returns_token(client: FlaskClient) -> None:
|
||||
response = client.post("/api/runs")
|
||||
assert response.status_code == 201
|
||||
token = response.get_json()["run_token"]
|
||||
assert isinstance(token, str) and len(token) > 20
|
||||
assert client.post("/api/runs").get_json()["run_token"] != token
|
||||
|
||||
|
||||
# ----------------------------------------------------------- POST /api/highscores: Erfolg
|
||||
|
||||
|
||||
def test_submit_valid_highscore(client: FlaskClient, valid_payload: Callable[..., dict]) -> None:
|
||||
response = client.post("/api/highscores", json=valid_payload())
|
||||
assert response.status_code == 201
|
||||
body = response.get_json()
|
||||
assert body["rank"] == 1
|
||||
assert body["name"] == "Kadettin Ada"
|
||||
assert body["highscores"][0]["score"] == 1234
|
||||
|
||||
listed = client.get("/api/highscores").get_json()["highscores"]
|
||||
assert listed[0]["name"] == "Kadettin Ada"
|
||||
assert listed[0]["wave"] == 3
|
||||
|
||||
|
||||
def test_submit_returns_rank_below_existing(
|
||||
app: Flask, client: FlaskClient, valid_payload: Callable[..., dict]
|
||||
) -> None:
|
||||
_seed(app, [("Profi", 5000), ("Mittel", 1500), ("Anfänger", 100)])
|
||||
body = client.post("/api/highscores", json=valid_payload(score=1000)).get_json()
|
||||
assert body["rank"] == 3
|
||||
|
||||
|
||||
def test_submit_sanitizes_name(client: FlaskClient, valid_payload: Callable[..., dict]) -> None:
|
||||
payload = valid_payload(name=" <b>Jürg</b>\u200b Größli \x00 ")
|
||||
body = client.post("/api/highscores", json=payload).get_json()
|
||||
assert body["name"] == "bJürgb Größli"
|
||||
|
||||
|
||||
# ------------------------------------------------------ POST /api/highscores: Validierung
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "field"),
|
||||
[
|
||||
({"name": ""}, "name"),
|
||||
({"name": " "}, "name"),
|
||||
({"name": "<>{}"}, "name"),
|
||||
({"name": "A" * 17}, "name"),
|
||||
({"name": "A" * 500}, "name"),
|
||||
({"name": 42}, "name"),
|
||||
({"name": None}, "name"),
|
||||
({"score": -1}, "score"),
|
||||
({"score": "1000"}, "score"),
|
||||
({"score": 12.5}, "score"),
|
||||
({"score": True}, "score"),
|
||||
({"score": None}, "score"),
|
||||
({"score": 10**12}, "score"),
|
||||
({"wave": 0}, "wave"),
|
||||
({"wave": "3"}, "wave"),
|
||||
({"wave": 100_000}, "wave"),
|
||||
({"duration_s": -5}, "duration_s"),
|
||||
({"duration_s": 10**9}, "duration_s"),
|
||||
({"run_token": ""}, "run_token"),
|
||||
({"run_token": 123}, "run_token"),
|
||||
({"run_token": "x" * 2000}, "run_token"),
|
||||
],
|
||||
)
|
||||
def test_submit_rejects_invalid_fields(
|
||||
client: FlaskClient, valid_payload: Callable[..., dict], overrides: dict, field: str
|
||||
) -> None:
|
||||
response = client.post("/api/highscores", json=valid_payload(**overrides))
|
||||
assert response.status_code == 400
|
||||
body = response.get_json()
|
||||
assert body["error"] == "validation_failed"
|
||||
assert field in body["fields"]
|
||||
|
||||
|
||||
def test_submit_reports_all_invalid_fields_at_once(client: FlaskClient) -> None:
|
||||
response = client.post("/api/highscores", json={})
|
||||
assert response.status_code == 400
|
||||
assert set(response.get_json()["fields"]) == {
|
||||
"name",
|
||||
"score",
|
||||
"wave",
|
||||
"duration_s",
|
||||
"run_token",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"data": "kein json", "content_type": "application/json"},
|
||||
{"data": "name=Ada&score=5", "content_type": "application/x-www-form-urlencoded"},
|
||||
{"json": ["liste", "statt", "objekt"]},
|
||||
{"json": "nur ein string"},
|
||||
],
|
||||
)
|
||||
def test_submit_rejects_non_object_bodies(client: FlaskClient, kwargs: dict) -> None:
|
||||
response = client.post("/api/highscores", **kwargs)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["error"] == "invalid_json"
|
||||
|
||||
|
||||
def test_submit_rejects_oversized_body(client: FlaskClient) -> None:
|
||||
response = client.post("/api/highscores", data="x" * 20_000, content_type="application/json")
|
||||
assert response.status_code == 413
|
||||
assert response.is_json
|
||||
|
||||
|
||||
# ------------------------------------------------------ POST /api/highscores: Run-Token
|
||||
|
||||
|
||||
def test_submit_rejects_forged_token(
|
||||
client: FlaskClient, valid_payload: Callable[..., dict]
|
||||
) -> None:
|
||||
response = client.post("/api/highscores", json=valid_payload(run_token="gefälscht.token.xyz"))
|
||||
assert response.status_code == 422
|
||||
assert response.get_json()["error"] == "run_token_invalid"
|
||||
|
||||
|
||||
def test_submit_rejects_token_signed_with_other_key(
|
||||
client: FlaskClient, valid_payload: Callable[..., dict], db_path
|
||||
) -> None:
|
||||
from app import create_app
|
||||
|
||||
other = create_app(
|
||||
{"TESTING": True, "SECRET_KEY": "anderer-key", "DATABASE_PATH": str(db_path)}
|
||||
).test_client()
|
||||
foreign_token = other.post("/api/runs").get_json()["run_token"]
|
||||
response = client.post("/api/highscores", json=valid_payload(run_token=foreign_token))
|
||||
assert response.get_json()["error"] == "run_token_invalid"
|
||||
|
||||
|
||||
def test_submit_rejects_expired_token(
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
issue_token: Callable[..., str],
|
||||
valid_payload: Callable[..., dict],
|
||||
) -> None:
|
||||
token = issue_token(started_ago=app.config["RUN_TOKEN_MAX_AGE_SECONDS"] + 60)
|
||||
response = client.post("/api/highscores", json=valid_payload(run_token=token))
|
||||
assert response.status_code == 422
|
||||
assert response.get_json()["error"] == "run_token_expired"
|
||||
|
||||
|
||||
def test_submit_rejects_reused_token(
|
||||
client: FlaskClient, issue_token: Callable[..., str], valid_payload: Callable[..., dict]
|
||||
) -> None:
|
||||
token = issue_token(started_ago=200)
|
||||
assert client.post("/api/highscores", json=valid_payload(run_token=token)).status_code == 201
|
||||
response = client.post("/api/highscores", json=valid_payload(run_token=token))
|
||||
assert response.status_code == 409
|
||||
assert response.get_json()["error"] == "run_token_used"
|
||||
|
||||
|
||||
# -------------------------------------------------- POST /api/highscores: Plausibilität
|
||||
|
||||
|
||||
def test_submit_rejects_duration_longer_than_run(
|
||||
client: FlaskClient, issue_token: Callable[..., str], valid_payload: Callable[..., dict]
|
||||
) -> None:
|
||||
token = issue_token(started_ago=60)
|
||||
response = client.post(
|
||||
"/api/highscores", json=valid_payload(run_token=token, duration_s=600, score=10)
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert response.get_json()["error"] == "implausible_score"
|
||||
|
||||
|
||||
def test_submit_rejects_score_too_high_for_duration(
|
||||
client: FlaskClient, valid_payload: Callable[..., dict]
|
||||
) -> None:
|
||||
response = client.post("/api/highscores", json=valid_payload(score=5_000_000, duration_s=60))
|
||||
assert response.status_code == 422
|
||||
assert response.get_json()["error"] == "implausible_score"
|
||||
|
||||
|
||||
def test_submit_rejects_wave_unreachable_in_duration(
|
||||
client: FlaskClient, valid_payload: Callable[..., dict]
|
||||
) -> None:
|
||||
response = client.post("/api/highscores", json=valid_payload(wave=80, duration_s=30, score=10))
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_implausible_submission_does_not_burn_token(
|
||||
client: FlaskClient, issue_token: Callable[..., str], valid_payload: Callable[..., dict]
|
||||
) -> None:
|
||||
token = issue_token(started_ago=120)
|
||||
bad = client.post("/api/highscores", json=valid_payload(run_token=token, score=9_999_999))
|
||||
assert bad.status_code == 422
|
||||
good = client.post("/api/highscores", json=valid_payload(run_token=token))
|
||||
assert good.status_code == 201
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ Rate-Limit
|
||||
|
||||
|
||||
def test_rate_limit_blocks_after_max_attempts(
|
||||
client: FlaskClient, valid_payload: Callable[..., dict]
|
||||
) -> None:
|
||||
for _ in range(5):
|
||||
assert client.post("/api/highscores", json=valid_payload()).status_code == 201
|
||||
response = client.post("/api/highscores", json=valid_payload())
|
||||
assert response.status_code == 429
|
||||
body = response.get_json()
|
||||
assert body["error"] == "rate_limited"
|
||||
retry_after = int(response.headers["Retry-After"])
|
||||
assert 1 <= retry_after <= 600
|
||||
assert body["retry_after"] == retry_after
|
||||
|
||||
|
||||
def test_rate_limit_counts_invalid_requests_too(client: FlaskClient) -> None:
|
||||
for _ in range(5):
|
||||
assert client.post("/api/highscores", json={}).status_code == 400
|
||||
assert client.post("/api/highscores", json={}).status_code == 429
|
||||
|
||||
|
||||
def test_rate_limit_is_per_client(client: FlaskClient, valid_payload: Callable[..., dict]) -> None:
|
||||
for _ in range(5):
|
||||
client.post("/api/highscores", json={}, environ_base={"REMOTE_ADDR": "10.0.0.1"})
|
||||
blocked = client.post("/api/highscores", json={}, environ_base={"REMOTE_ADDR": "10.0.0.1"})
|
||||
assert blocked.status_code == 429
|
||||
other = client.post(
|
||||
"/api/highscores", json=valid_payload(), environ_base={"REMOTE_ADDR": "10.0.0.2"}
|
||||
)
|
||||
assert other.status_code == 201
|
||||
|
||||
|
||||
def test_rate_limit_window_expires(
|
||||
app: Flask, client: FlaskClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import app.api as api_module
|
||||
|
||||
fake_now = [1_000_000.0]
|
||||
monkeypatch.setattr(api_module, "_now", lambda: fake_now[0])
|
||||
for _ in range(5):
|
||||
client.post("/api/highscores", json={})
|
||||
assert client.post("/api/highscores", json={}).status_code == 429
|
||||
|
||||
fake_now[0] += app.config["RATE_LIMIT_WINDOW_SECONDS"] + 1
|
||||
assert client.post("/api/highscores", json={}).status_code == 400
|
||||
|
||||
|
||||
def test_rate_limit_does_not_store_plain_ip(app: Flask, client: FlaskClient) -> None:
|
||||
client.post("/api/highscores", json={}, environ_base={"REMOTE_ADDR": "203.0.113.7"})
|
||||
with app.app_context():
|
||||
hashes = [row[0] for row in get_db().execute("SELECT client_hash FROM submission_log")]
|
||||
assert hashes
|
||||
assert all("203.0.113.7" not in value for value in hashes)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Prüft Assets und Inhalte: Lizenzdokumentation vollständig, Verweise gültig,
|
||||
SVGs harmlos, keine Namen aus anderen Spielen oder Filmen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import PROJECT_ROOT
|
||||
|
||||
STATIC = PROJECT_ROOT / "app" / "static"
|
||||
ASSETS = STATIC / "assets"
|
||||
ASSETS_DOC = PROJECT_ROOT / "docs" / "ASSETS.md"
|
||||
JS_ROOT = STATIC / "js"
|
||||
|
||||
#: Begriffe aus Helldivers bzw. Starship Troopers, die im Spiel nicht vorkommen dürfen
|
||||
#: (Groß-/Kleinschreibung egal). Der allgemeine Tonfall darf inspiriert sein, Namen nicht.
|
||||
FORBIDDEN_TERMS = [
|
||||
"helldiver",
|
||||
"super earth",
|
||||
"super-erde",
|
||||
"supererde",
|
||||
"stratagem",
|
||||
"hellpod",
|
||||
"terminid",
|
||||
"automaton",
|
||||
"illuminate",
|
||||
"managed democracy",
|
||||
"gelenkte demokratie",
|
||||
"liber-tea",
|
||||
"would you like to know more",
|
||||
"möchten sie mehr erfahren",
|
||||
"mobile infantry",
|
||||
"arachnid",
|
||||
"federation",
|
||||
"föderation",
|
||||
"service guarantees citizenship",
|
||||
"rico",
|
||||
"klendathu",
|
||||
]
|
||||
|
||||
|
||||
def _asset_files() -> list[Path]:
|
||||
return sorted(p for p in ASSETS.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
def test_asset_folder_is_not_empty() -> None:
|
||||
assert _asset_files(), "Keine Assets gefunden"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", _asset_files(), ids=lambda p: p.relative_to(ASSETS).as_posix())
|
||||
def test_every_asset_is_documented(path: Path) -> None:
|
||||
documented = ASSETS_DOC.read_text(encoding="utf-8")
|
||||
relative = path.relative_to(ASSETS).as_posix()
|
||||
assert relative in documented, f"{relative} fehlt in docs/ASSETS.md"
|
||||
|
||||
|
||||
def test_referenced_asset_files_exist() -> None:
|
||||
manifest = (JS_ROOT / "systems" / "Assets.js").read_text(encoding="utf-8")
|
||||
referenced = re.findall(r"file: '([^']+)'", manifest)
|
||||
assert len(referenced) >= 15
|
||||
missing = [f for f in referenced if not (ASSETS / f).is_file()]
|
||||
assert missing == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", sorted(ASSETS.rglob("*.svg")), ids=lambda p: p.name)
|
||||
def test_svgs_are_well_formed_and_self_contained(path: Path) -> None:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
root = ET.fromstring(source) # noqa: S314 – eigene, versionierte Dateien
|
||||
assert root.tag.endswith("svg")
|
||||
lowered = source.lower()
|
||||
# Keine Skripte, keine externen Verweise: SVGs sollen reine Grafik sein.
|
||||
assert "<script" not in lowered
|
||||
assert "http://" not in lowered.replace("http://www.w3.org/2000/svg", "")
|
||||
assert "https://" not in lowered
|
||||
assert "<image" not in lowered
|
||||
|
||||
|
||||
def test_phaser_is_vendored_locally() -> None:
|
||||
phaser = JS_ROOT / "lib" / "phaser.min.js"
|
||||
assert phaser.is_file()
|
||||
assert phaser.stat().st_size > 500_000
|
||||
head = phaser.read_text(encoding="utf-8", errors="ignore")[:100_000]
|
||||
assert '"3.90.0"' in head or "3.90.0" in head
|
||||
assert (JS_ROOT / "lib" / "PHASER_LICENSE.md").is_file()
|
||||
|
||||
|
||||
def test_no_external_urls_in_page_or_scripts() -> None:
|
||||
"""Das Spiel lädt nichts von fremden Servern (kein CDN, kein Tracking)."""
|
||||
offenders = []
|
||||
for path in [*JS_ROOT.rglob("*.js"), *(PROJECT_ROOT / "app" / "templates").rglob("*.html")]:
|
||||
if "lib" in path.relative_to(STATIC if path.is_relative_to(STATIC) else PROJECT_ROOT).parts:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for match in re.findall(r"""(?:src|href)=["'](https?://[^"']+)""", text):
|
||||
offenders.append(f"{path.name}: {match}")
|
||||
if re.search(r"""\b(?:import|fetch)\s*\(?\s*["']https?://""", text):
|
||||
offenders.append(f"{path.name}: externer Import/Fetch")
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_no_names_from_other_games_or_films() -> None:
|
||||
sources = [
|
||||
*JS_ROOT.rglob("*.js"),
|
||||
*(PROJECT_ROOT / "app" / "templates").rglob("*.html"),
|
||||
*ASSETS.rglob("*.svg"),
|
||||
]
|
||||
hits = []
|
||||
for path in sources:
|
||||
if "lib" in path.parts:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8").lower()
|
||||
for term in FORBIDDEN_TERMS:
|
||||
if re.search(rf"\b{re.escape(term)}\b", text):
|
||||
hits.append(f"{path.relative_to(PROJECT_ROOT).as_posix()}: {term}")
|
||||
assert hits == []
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests für das Laden der Konfiguration aus Umgebungsvariablen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import config as config_module
|
||||
from app import create_app
|
||||
from app.config import (
|
||||
PROJECT_ROOT,
|
||||
ensure_secret_key,
|
||||
env_bool,
|
||||
env_int,
|
||||
load_config,
|
||||
load_or_create_secret_key,
|
||||
resolve_project_path,
|
||||
)
|
||||
|
||||
|
||||
def test_relative_paths_are_anchored_at_project_root() -> None:
|
||||
assert resolve_project_path("instance/x.sqlite3") == PROJECT_ROOT / "instance" / "x.sqlite3"
|
||||
|
||||
|
||||
def test_absolute_paths_stay_unchanged(tmp_path: Path) -> None:
|
||||
assert resolve_project_path(tmp_path / "db.sqlite3") == tmp_path / "db.sqlite3"
|
||||
|
||||
|
||||
def test_env_int(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GD_TEST_INT", raising=False)
|
||||
assert env_int("GD_TEST_INT", 7) == 7
|
||||
monkeypatch.setenv("GD_TEST_INT", " 12 ")
|
||||
assert env_int("GD_TEST_INT", 7) == 12
|
||||
monkeypatch.setenv("GD_TEST_INT", "zwölf")
|
||||
with pytest.raises(ValueError, match="ganze Zahl"):
|
||||
env_int("GD_TEST_INT", 7)
|
||||
monkeypatch.setenv("GD_TEST_INT", "0")
|
||||
with pytest.raises(ValueError, match="mindestens"):
|
||||
env_int("GD_TEST_INT", 7, minimum=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"), [("1", True), ("ja", True), ("0", False), ("", False)]
|
||||
)
|
||||
def test_env_bool(monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool) -> None:
|
||||
monkeypatch.setenv("GD_TEST_BOOL", raw)
|
||||
assert env_bool("GD_TEST_BOOL", default=False) is expected
|
||||
|
||||
|
||||
def test_secret_key_file_is_created_once(tmp_path: Path) -> None:
|
||||
key_file = tmp_path / "instance" / "secret_key"
|
||||
first = load_or_create_secret_key(key_file)
|
||||
second = load_or_create_secret_key(key_file)
|
||||
assert first == second
|
||||
assert len(first) == 64
|
||||
|
||||
|
||||
def test_load_config_reads_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("SECRET_KEY", "aus-der-umgebung")
|
||||
monkeypatch.setenv("GLORY_DB_PATH", str(tmp_path / "x.sqlite3"))
|
||||
monkeypatch.setenv("GLORY_RATE_LIMIT_MAX", "9")
|
||||
monkeypatch.setenv("GLORY_RATE_LIMIT_WINDOW", "30")
|
||||
config = load_config()
|
||||
assert config["SECRET_KEY"] == "aus-der-umgebung"
|
||||
assert config["DATABASE_PATH"] == str(tmp_path / "x.sqlite3")
|
||||
assert config["RATE_LIMIT_MAX_SUBMISSIONS"] == 9
|
||||
assert config["RATE_LIMIT_WINDOW_SECONDS"] == 30
|
||||
|
||||
|
||||
def test_generated_secret_is_created_only_when_needed(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Ohne SECRET_KEY entsteht die Schlüsseldatei erst beim ersten Bedarf.
|
||||
|
||||
Befehle im cPanel-Terminal (Datenbank anlegen, Ehrentafel moderieren) dürfen keine
|
||||
überflüssige Schlüsseldatei erzeugen.
|
||||
"""
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
key_file = tmp_path / "secret_key"
|
||||
monkeypatch.setattr(config_module, "SECRET_KEY_FILE", key_file)
|
||||
config = load_config()
|
||||
assert config["SECRET_KEY"] is None
|
||||
assert not key_file.exists()
|
||||
|
||||
key = ensure_secret_key(config)
|
||||
assert key == key_file.read_text(encoding="utf-8")
|
||||
assert config["SECRET_KEY"] == key
|
||||
assert ensure_secret_key(config) == key
|
||||
|
||||
|
||||
def test_app_without_secret_creates_key_on_first_token(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
key_file = tmp_path / "secret_key"
|
||||
monkeypatch.setattr(config_module, "SECRET_KEY_FILE", key_file)
|
||||
app = create_app({"DATABASE_PATH": str(tmp_path / "db.sqlite3")})
|
||||
client = app.test_client()
|
||||
|
||||
assert client.get("/").status_code == 200
|
||||
assert client.get("/healthz").status_code == 200
|
||||
assert app.test_cli_runner().invoke(args=["scores", "list"]).exit_code == 0
|
||||
assert not key_file.exists()
|
||||
|
||||
assert client.post("/api/runs").status_code == 201
|
||||
assert key_file.exists()
|
||||
assert app.config["SECRET_KEY"] == key_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_blocklist_path_from_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("GLORY_NAME_BLOCKLIST", str(tmp_path / "sperre.txt"))
|
||||
assert load_config()["NAME_BLOCKLIST_PATH"] == str(tmp_path / "sperre.txt")
|
||||
monkeypatch.delenv("GLORY_NAME_BLOCKLIST")
|
||||
assert load_config()["NAME_BLOCKLIST_PATH"] == str(
|
||||
PROJECT_ROOT / "instance" / "name_blocklist.txt"
|
||||
)
|
||||
|
||||
|
||||
def test_overrides_take_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SECRET_KEY", "aus-der-umgebung")
|
||||
assert load_config({"SECRET_KEY": "override"})["SECRET_KEY"] == "override"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests für das Upload-Paket (scripts/build_deploy_zip.py).
|
||||
|
||||
Prüft, dass das Paket vollständig ist, keine Geheimnisse oder Caches enthält, nur
|
||||
„/“ als Pfadtrenner nutzt und nach dem Entpacken wie auf cPanel startet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
from app.config import PROJECT_ROOT
|
||||
|
||||
|
||||
def _load_builder() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"build_deploy_zip", PROJECT_ROOT / "scripts" / "build_deploy_zip.py"
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_zip_contains_everything_the_server_needs(tmp_path: Path) -> None:
|
||||
target = tmp_path / "paket.zip"
|
||||
names = _load_builder().build_zip(target)
|
||||
with zipfile.ZipFile(target) as archive:
|
||||
archived = set(archive.namelist())
|
||||
assert archived == set(names)
|
||||
for required in (
|
||||
"passenger_wsgi.py",
|
||||
"requirements.txt",
|
||||
"app/__init__.py",
|
||||
"app/templates/index.html",
|
||||
"app/static/js/main.js",
|
||||
"app/static/js/lib/phaser.min.js",
|
||||
"app/static/assets/sprites/president.svg",
|
||||
):
|
||||
assert required in archived, required
|
||||
|
||||
|
||||
def test_zip_has_no_secrets_caches_or_backslashes(tmp_path: Path) -> None:
|
||||
target = tmp_path / "paket.zip"
|
||||
names = _load_builder().build_zip(target)
|
||||
assert all("\\" not in name for name in names)
|
||||
assert not any(name == ".env" or name.startswith("instance/") for name in names)
|
||||
assert not any("__pycache__" in name or name.endswith(".pyc") for name in names)
|
||||
assert not any(name.startswith(("tests/", "node_modules/", ".venv/")) for name in names)
|
||||
|
||||
|
||||
def test_unpacked_zip_starts_like_on_cpanel(tmp_path: Path) -> None:
|
||||
"""Das entpackte Paket allein reicht: passenger_wsgi importiert die App und liefert Seiten."""
|
||||
target = tmp_path / "paket.zip"
|
||||
_load_builder().build_zip(target)
|
||||
deploy_root = tmp_path / "glory_defense"
|
||||
with zipfile.ZipFile(target) as archive:
|
||||
archive.extractall(deploy_root)
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith(("GLORY_", "SECRET_KEY"))}
|
||||
env["SECRET_KEY"] = "deploy-test"
|
||||
script = (
|
||||
"import runpy\n"
|
||||
f"app = runpy.run_path({str(deploy_root / 'passenger_wsgi.py')!r})['application']\n"
|
||||
"c = app.test_client()\n"
|
||||
"print(c.get('/').status_code, c.get('/static/js/lib/phaser.min.js').status_code,"
|
||||
" c.get('/api/highscores').status_code)\n"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=tmp_path,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "200 200 200"
|
||||
# Datenbank entsteht im entpackten Projekt, nicht im Arbeitsverzeichnis.
|
||||
assert (deploy_root / "instance" / "glory_defense.sqlite3").exists()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests für Datenbank-Initialisierung und die SQLite-Hilfsfunktionen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from app import create_app
|
||||
from app.models import (
|
||||
SCHEMA_VERSION,
|
||||
connect,
|
||||
consume_run_token,
|
||||
init_db,
|
||||
insert_score,
|
||||
rank_of,
|
||||
register_submission_attempt,
|
||||
top_scores,
|
||||
)
|
||||
|
||||
|
||||
def _tables(path: Path) -> set[str]:
|
||||
with sqlite3.connect(path) as conn:
|
||||
rows = conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
|
||||
return {row[0] for row in rows}
|
||||
|
||||
|
||||
def test_create_app_initializes_database(app: Flask, db_path: Path) -> None:
|
||||
assert db_path.exists()
|
||||
assert {"highscores", "submission_log", "used_run_tokens"} <= _tables(db_path)
|
||||
|
||||
|
||||
def test_init_db_creates_missing_parent_directories(tmp_path: Path) -> None:
|
||||
target = tmp_path / "tief" / "verschachtelt" / "scores.sqlite3"
|
||||
init_db(target)
|
||||
assert target.exists()
|
||||
with sqlite3.connect(target) as conn:
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_init_db_is_idempotent_and_keeps_data(tmp_path: Path) -> None:
|
||||
target = tmp_path / "scores.sqlite3"
|
||||
init_db(target)
|
||||
conn = connect(target)
|
||||
insert_score(conn, "Bleibt", 42, wave=1, duration_s=10)
|
||||
conn.close()
|
||||
|
||||
init_db(target)
|
||||
init_db(target)
|
||||
conn = connect(target)
|
||||
assert [row["name"] for row in top_scores(conn, 10)] == ["Bleibt"]
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_db_path_from_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
target = tmp_path / "env" / "glory.sqlite3"
|
||||
monkeypatch.setenv("GLORY_DB_PATH", str(target))
|
||||
monkeypatch.setenv("SECRET_KEY", "env-secret")
|
||||
app = create_app()
|
||||
assert Path(app.config["DATABASE_PATH"]) == target
|
||||
assert target.exists()
|
||||
|
||||
|
||||
def test_init_db_cli_command(app: Flask, db_path: Path) -> None:
|
||||
db_path.unlink()
|
||||
result = app.test_cli_runner().invoke(args=["init-db"])
|
||||
assert result.exit_code == 0
|
||||
assert "Datenbank bereit" in result.output
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
def test_rank_of_matches_top_scores_order(tmp_path: Path) -> None:
|
||||
target = tmp_path / "rank.sqlite3"
|
||||
init_db(target)
|
||||
conn = connect(target)
|
||||
ids = [
|
||||
insert_score(conn, name, score, 1, 1)
|
||||
for name, score in [("A", 300), ("B", 500), ("C", 300), ("D", 100)]
|
||||
]
|
||||
ranks = [rank_of(conn, score_id) for score_id in ids]
|
||||
assert ranks == [2, 1, 3, 4]
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_register_submission_attempt_limits_and_recovers(tmp_path: Path) -> None:
|
||||
target = tmp_path / "limit.sqlite3"
|
||||
init_db(target)
|
||||
conn = connect(target)
|
||||
now = 5_000.0
|
||||
results = [register_submission_attempt(conn, "client", now + i, 60, 3) for i in range(4)]
|
||||
assert [allowed for allowed, _ in results] == [True, True, True, False]
|
||||
# Ältester Versuch bei t=5000 fällt bei t=5060 aus dem Fenster; Anfrage kam bei t=5003.
|
||||
assert results[3][1] == 57
|
||||
assert register_submission_attempt(conn, "client", now + 61, 60, 3)[0] is True
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_consume_run_token_only_once(tmp_path: Path) -> None:
|
||||
target = tmp_path / "tokens.sqlite3"
|
||||
init_db(target)
|
||||
conn = connect(target)
|
||||
assert consume_run_token(conn, "run-1", 100.0, 3600) is True
|
||||
assert consume_run_token(conn, "run-1", 101.0, 3600) is False
|
||||
assert consume_run_token(conn, "run-2", 101.0, 3600) is True
|
||||
conn.close()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests für die Moderation der Ehrentafel: CLI-Befehle und Sperrliste für Namen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from app import create_app
|
||||
from app.models import get_db, insert_score, top_scores
|
||||
from app.validation import is_name_blocked, load_blocklist, normalize_for_blocklist
|
||||
|
||||
|
||||
def _seed(app: Flask, entries: list[tuple[str, int]]) -> list[int]:
|
||||
with app.app_context():
|
||||
return [
|
||||
insert_score(get_db(), name, score, wave=1, duration_s=60) for name, score in entries
|
||||
]
|
||||
|
||||
|
||||
def _names(app: Flask) -> list[str]:
|
||||
with app.app_context():
|
||||
return [entry["name"] for entry in top_scores(get_db(), 100)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def test_scores_list_shows_ids_and_names(app: Flask) -> None:
|
||||
ids = _seed(app, [("Ada", 300), ("Grace", 500)])
|
||||
result = app.test_cli_runner().invoke(args=["scores", "list"])
|
||||
assert result.exit_code == 0
|
||||
lines = result.output.splitlines()
|
||||
assert "Name" in lines[0]
|
||||
assert str(ids[1]) in lines[1] and "Grace" in lines[1]
|
||||
assert str(ids[0]) in lines[2] and "Ada" in lines[2]
|
||||
|
||||
|
||||
def test_scores_list_when_empty(app: Flask) -> None:
|
||||
result = app.test_cli_runner().invoke(args=["scores", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "Keine Einträge" in result.output
|
||||
|
||||
|
||||
def test_scores_delete_by_id(app: Flask) -> None:
|
||||
ids = _seed(app, [("Bleibt", 100), ("Weg", 200), ("AuchWeg", 300)])
|
||||
result = app.test_cli_runner().invoke(
|
||||
args=["scores", "delete", str(ids[1]), str(ids[2]), "999"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "2 Eintrag/Einträge gelöscht" in result.output
|
||||
assert _names(app) == ["Bleibt"]
|
||||
|
||||
|
||||
def test_scores_delete_requires_ids(app: Flask) -> None:
|
||||
result = app.test_cli_runner().invoke(args=["scores", "delete"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_scores_delete_name_is_case_insensitive(app: Flask) -> None:
|
||||
_seed(app, [("Unerwünscht", 100), ("unerwünscht", 200), ("Ada", 300)])
|
||||
# Auch Umlaute zählen ohne Groß-/Kleinschreibung (SQLites NOCASE könnte das nicht).
|
||||
result = app.test_cli_runner().invoke(args=["scores", "delete-name", "UNERWÜNSCHT"])
|
||||
assert result.exit_code == 0
|
||||
assert "2 Eintrag/Einträge" in result.output
|
||||
assert _names(app) == ["Ada"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- Sperrliste
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("Max Muster", "maxmuster"),
|
||||
("M.a.x-Muster!", "maxmuster"),
|
||||
("MAX", "max"),
|
||||
("Jürg", "jürg"),
|
||||
],
|
||||
)
|
||||
def test_normalize_for_blocklist(raw: str, expected: str) -> None:
|
||||
assert normalize_for_blocklist(raw) == expected
|
||||
|
||||
|
||||
def test_load_blocklist_ignores_comments_and_blank_lines(tmp_path: Path) -> None:
|
||||
path = tmp_path / "sperre.txt"
|
||||
path.write_text("# Kommentar\n\n Max Muster \nbeleidigung\n#noch einer\n", encoding="utf-8")
|
||||
assert load_blocklist(path) == ["maxmuster", "beleidigung"]
|
||||
|
||||
|
||||
def test_missing_blocklist_blocks_nothing(tmp_path: Path) -> None:
|
||||
assert load_blocklist(tmp_path / "gibt-es-nicht.txt") == []
|
||||
assert is_name_blocked("Irgendwer", []) is False
|
||||
|
||||
|
||||
def test_blocked_terms_cannot_be_bypassed_by_spacing_or_case() -> None:
|
||||
terms = ["maxmuster"]
|
||||
assert is_name_blocked("Max Muster", terms)
|
||||
assert is_name_blocked("xX.MAX.muster.Xx", terms)
|
||||
assert not is_name_blocked("Max Mustang", terms)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def blocklist_app(tmp_path: Path) -> Flask:
|
||||
blocklist = tmp_path / "name_blocklist.txt"
|
||||
blocklist.write_text("Max Muster\n", encoding="utf-8")
|
||||
return create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SECRET_KEY": "test-secret-key",
|
||||
"DATABASE_PATH": str(tmp_path / "db.sqlite3"),
|
||||
"NAME_BLOCKLIST_PATH": str(blocklist),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_api_rejects_blocked_name(blocklist_app: Flask) -> None:
|
||||
client: FlaskClient = blocklist_app.test_client()
|
||||
token = client.post("/api/runs").get_json()["run_token"]
|
||||
payload = {"name": "max.muster", "score": 10, "wave": 1, "duration_s": 0, "run_token": token}
|
||||
response = client.post("/api/highscores", json=payload)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["fields"] == {"name": "Dieser Name ist nicht zulässig."}
|
||||
assert _names(blocklist_app) == []
|
||||
|
||||
|
||||
def test_api_accepts_other_names_with_blocklist(blocklist_app: Flask) -> None:
|
||||
client: FlaskClient = blocklist_app.test_client()
|
||||
token = client.post("/api/runs").get_json()["run_token"]
|
||||
payload = {"name": "Ada", "score": 10, "wave": 1, "duration_s": 0, "run_token": token}
|
||||
assert client.post("/api/highscores", json=payload).status_code == 201
|
||||
|
||||
|
||||
def test_api_without_blocklist_file_accepts_names(
|
||||
client: FlaskClient, valid_payload: Callable[..., dict]
|
||||
) -> None:
|
||||
assert client.post("/api/highscores", json=valid_payload(name="Max Muster")).status_code == 201
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests für den Passenger-Einstiegspunkt (cPanel „Setup Python App“)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from app.config import PROJECT_ROOT
|
||||
|
||||
PASSENGER_FILE = PROJECT_ROOT / "passenger_wsgi.py"
|
||||
|
||||
|
||||
def _clean_env(tmp_path: Path) -> dict[str, str]:
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith(("GLORY_", "SECRET_KEY"))}
|
||||
env["SECRET_KEY"] = "passenger-test"
|
||||
env["GLORY_DB_PATH"] = str(tmp_path / "passenger.sqlite3")
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolated_environ() -> Iterator[None]:
|
||||
"""Stellt os.environ nach dem Test wieder her.
|
||||
|
||||
passenger_wsgi.py lädt eine vorhandene .env-Datei in os.environ. Ohne diese
|
||||
Isolation würden deren Werte (z. B. GLORY_RATE_LIMIT_MAX) in spätere Tests durchsickern.
|
||||
"""
|
||||
saved = dict(os.environ)
|
||||
yield
|
||||
os.environ.clear()
|
||||
os.environ.update(saved)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("isolated_environ")
|
||||
def test_passenger_file_exposes_wsgi_application(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("SECRET_KEY", "passenger-test")
|
||||
monkeypatch.setenv("GLORY_DB_PATH", str(tmp_path / "passenger.sqlite3"))
|
||||
spec = importlib.util.spec_from_file_location("passenger_wsgi_under_test", PASSENGER_FILE)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
application = module.application
|
||||
assert isinstance(application, Flask)
|
||||
client = application.test_client()
|
||||
assert client.get("/").status_code == 200
|
||||
assert client.get("/healthz").get_json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_passenger_import_works_from_foreign_working_directory(tmp_path: Path) -> None:
|
||||
"""Passenger startet nicht zwingend im Projektordner – der Import muss trotzdem klappen."""
|
||||
script = (
|
||||
"import runpy, sys\n"
|
||||
f"ns = runpy.run_path({str(PASSENGER_FILE)!r})\n"
|
||||
"app = ns['application']\n"
|
||||
"print(type(app).__name__, app.test_client().get('/healthz').status_code)\n"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=tmp_path,
|
||||
env=_clean_env(tmp_path),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "Flask 200"
|
||||
assert (tmp_path / "passenger.sqlite3").exists()
|
||||
|
||||
|
||||
def test_passenger_file_uses_only_relative_project_paths() -> None:
|
||||
source = PASSENGER_FILE.read_text(encoding="utf-8")
|
||||
assert "application = create_app()" in source
|
||||
# Keine fest verdrahteten Pfade (z. B. /home/user/...), sonst bricht es beim Umzug.
|
||||
assert "/home/" not in source
|
||||
assert "C:\\" not in source
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests für die HTML-Seiten, den Health-Check und allgemeine HTTP-Eigenschaften."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
def test_index_serves_game_page(client: FlaskClient) -> None:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
html = response.get_data(as_text=True)
|
||||
assert 'id="game"' in html
|
||||
assert "js/lib/phaser.min.js" in html
|
||||
assert 'type="module"' in html
|
||||
# Der Disclaimer-Text ist für die Einblendung im Spiel eingebettet.
|
||||
assert "Satire und reine Fiktion" in html
|
||||
|
||||
|
||||
def test_index_passes_urls_to_frontend(client: FlaskClient) -> None:
|
||||
html = client.get("/").get_data(as_text=True)
|
||||
assert 'data-api-highscores="/api/highscores"' in html
|
||||
assert 'data-api-runs="/api/runs"' in html
|
||||
assert 'data-static-base="/static/"' in html
|
||||
|
||||
|
||||
def test_index_respects_mount_prefix(client: FlaskClient) -> None:
|
||||
"""Unter cPanel kann die App unter einer Unter-URL laufen (SCRIPT_NAME)."""
|
||||
html = client.get("/", base_url="http://localhost/glory").get_data(as_text=True)
|
||||
assert 'data-api-highscores="/glory/api/highscores"' in html
|
||||
assert 'data-static-base="/glory/static/"' in html
|
||||
assert 'src="/glory/static/js/main.js"' in html
|
||||
|
||||
|
||||
def test_disclaimer_page(client: FlaskClient) -> None:
|
||||
response = client.get("/disclaimer")
|
||||
assert response.status_code == 200
|
||||
html = response.get_data(as_text=True)
|
||||
for phrase in (
|
||||
"Satire und reine Fiktion",
|
||||
"frei erfunden",
|
||||
"nicht beabsichtigt",
|
||||
"keine politische Position",
|
||||
):
|
||||
assert phrase in html
|
||||
assert 'href="/"' in html
|
||||
|
||||
|
||||
def test_healthz(client: FlaskClient) -> None:
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_security_headers_present(client: FlaskClient) -> None:
|
||||
response = client.get("/")
|
||||
csp = response.headers["Content-Security-Policy"]
|
||||
assert "script-src 'self'" in csp
|
||||
assert "object-src 'none'" in csp
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
|
||||
def test_unknown_page_returns_404_html(client: FlaskClient) -> None:
|
||||
response = client.get("/gibt-es-nicht")
|
||||
assert response.status_code == 404
|
||||
assert response.mimetype == "text/html"
|
||||
|
||||
|
||||
def test_unknown_api_route_returns_json_404(client: FlaskClient) -> None:
|
||||
response = client.get("/api/gibt-es-nicht")
|
||||
assert response.status_code == 404
|
||||
assert response.is_json
|
||||
assert response.get_json()["error"] == "not_found"
|
||||
|
||||
|
||||
def test_wrong_method_on_api_returns_json_405(client: FlaskClient) -> None:
|
||||
response = client.delete("/api/highscores")
|
||||
assert response.status_code == 405
|
||||
assert response.get_json()["error"] == "method_not_allowed"
|
||||
|
||||
|
||||
def test_forwarded_headers_are_ignored_without_trusted_proxy(client: FlaskClient) -> None:
|
||||
"""Ohne GLORY_TRUST_PROXY kann ein Client sein Rate-Limit nicht per Header umgehen."""
|
||||
for i in range(5):
|
||||
client.post("/api/highscores", json={}, headers={"X-Forwarded-For": f"198.51.100.{i}"})
|
||||
blocked = client.post("/api/highscores", json={}, headers={"X-Forwarded-For": "198.51.100.99"})
|
||||
assert blocked.status_code == 429
|
||||
|
||||
|
||||
def test_trusted_proxy_uses_client_ip_but_not_host_or_prefix(tmp_path: Path) -> None:
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SECRET_KEY": "k",
|
||||
"DATABASE_PATH": str(tmp_path / "db.sqlite3"),
|
||||
"TRUST_PROXY": True,
|
||||
}
|
||||
)
|
||||
client = app.test_client()
|
||||
# Host und Präfix aus Headern dürfen nicht in die erzeugten URLs gelangen.
|
||||
html = client.get(
|
||||
"/", headers={"X-Forwarded-Prefix": "//evil.example", "X-Forwarded-Host": "evil.example"}
|
||||
).get_data(as_text=True)
|
||||
assert "evil.example" not in html
|
||||
assert 'src="/static/js/main.js"' in html
|
||||
# Die weitergeleitete Client-IP zählt dagegen fürs Rate-Limit.
|
||||
for _ in range(5):
|
||||
client.post("/api/highscores", json={}, headers={"X-Forwarded-For": "198.51.100.1"})
|
||||
first = client.post("/api/highscores", json={}, headers={"X-Forwarded-For": "198.51.100.1"})
|
||||
other = client.post("/api/highscores", json={}, headers={"X-Forwarded-For": "198.51.100.2"})
|
||||
assert first.status_code == 429
|
||||
assert other.status_code == 400
|
||||
|
||||
|
||||
def test_api_responses_keep_field_order(client: FlaskClient) -> None:
|
||||
body = client.post("/api/highscores", json={}).get_data(as_text=True)
|
||||
assert body.index('"error"') < body.index('"message"') < body.index('"fields"')
|
||||
|
||||
|
||||
def test_static_phaser_and_main_are_served(client: FlaskClient) -> None:
|
||||
for path in ("/static/js/lib/phaser.min.js", "/static/js/main.js", "/static/css/site.css"):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200, path
|
||||
response.close()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests für Namensbereinigung und Plausibilitätsprüfung (ohne Flask)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import GAME_RULE_DEFAULTS
|
||||
from app.validation import (
|
||||
NAME_MAX_LENGTH,
|
||||
PlausibilityRules,
|
||||
ScoreSubmission,
|
||||
SubmissionError,
|
||||
check_plausibility,
|
||||
parse_submission,
|
||||
sanitize_name,
|
||||
)
|
||||
|
||||
RULES = PlausibilityRules.from_config(GAME_RULE_DEFAULTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("Ada", "Ada"),
|
||||
(" Max Muster ", "Max Muster"),
|
||||
("Jürg Größli", "Jürg Größli"),
|
||||
("Fullwidth", "Fullwidth"),
|
||||
("Null\x00Byte", "NullByte"),
|
||||
("Zero\u200bWidth", "ZeroWidth"),
|
||||
("Bidi\u202eTrick", "BidiTrick"),
|
||||
("Tab\tNeu\nZeile", "TabNeuZeile"),
|
||||
("<script>alert(1)</script>", "scriptalert1script"),
|
||||
("O'Neil-Test!?", "O'Neil-Test!?"),
|
||||
("Emoji 🚀 Held", "Emoji Held"),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_sanitize_name(raw: str, expected: str) -> None:
|
||||
assert sanitize_name(raw) == expected
|
||||
|
||||
|
||||
def _payload(**overrides: object) -> dict:
|
||||
payload: dict = {"name": "Ada", "score": 100, "wave": 2, "duration_s": 60, "run_token": "t"}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_parse_submission_valid() -> None:
|
||||
submission = parse_submission(_payload(name=" Ada "), RULES)
|
||||
assert submission == ScoreSubmission("Ada", 100, 2, 60, "t")
|
||||
|
||||
|
||||
def test_parse_submission_name_exactly_max_length() -> None:
|
||||
name = "N" * NAME_MAX_LENGTH
|
||||
assert parse_submission(_payload(name=name), RULES).name == name
|
||||
|
||||
|
||||
def test_parse_submission_collects_errors() -> None:
|
||||
with pytest.raises(SubmissionError) as info:
|
||||
parse_submission(_payload(name="", score=-1, wave=0), RULES)
|
||||
assert set(info.value.fields) == {"name", "score", "wave"}
|
||||
|
||||
|
||||
def _submission(score: int, wave: int, duration_s: int) -> ScoreSubmission:
|
||||
return ScoreSubmission("Ada", score, wave, duration_s, "t")
|
||||
|
||||
|
||||
def test_plausible_submission() -> None:
|
||||
assert check_plausibility(_submission(2_000, 3, 120), elapsed_s=150, rules=RULES) is None
|
||||
|
||||
|
||||
def test_duration_may_be_shorter_than_elapsed_because_of_pauses() -> None:
|
||||
assert check_plausibility(_submission(500, 2, 60), elapsed_s=3_000, rules=RULES) is None
|
||||
|
||||
|
||||
def test_duration_longer_than_elapsed_is_implausible() -> None:
|
||||
assert check_plausibility(_submission(500, 2, 600), elapsed_s=100, rules=RULES) is not None
|
||||
|
||||
|
||||
def test_duration_within_tolerance_is_accepted() -> None:
|
||||
tolerance = RULES.duration_tolerance_seconds
|
||||
assert check_plausibility(_submission(500, 2, 100 + tolerance), 100, RULES) is None
|
||||
|
||||
|
||||
def test_score_ceiling_depends_on_duration() -> None:
|
||||
ceiling = RULES.score_flat_allowance + 100 * RULES.score_max_points_per_second
|
||||
assert check_plausibility(_submission(ceiling, 5, 100), 200, RULES) is None
|
||||
assert check_plausibility(_submission(ceiling + 1, 5, 100), 200, RULES) is not None
|
||||
|
||||
|
||||
def test_high_wave_needs_minimum_time() -> None:
|
||||
assert check_plausibility(_submission(10, 50, 10), 500, RULES) is not None
|
||||
min_time = 49 * RULES.wave_min_seconds
|
||||
assert check_plausibility(_submission(10, 50, min_time), min_time + 10, RULES) is None
|
||||
Reference in New Issue
Block a user