slop game oder so

This commit is contained in:
2026-09-24 17:57:54 +02:00
commit 4c997eee21
166 changed files with 14976 additions and 0 deletions
+79
View File
@@ -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
+254
View File
@@ -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()
+82
View File
@@ -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' });
});
});
+242
View File
@@ -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);
});
});
+126
View File
@@ -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);
});
});
+114
View File
@@ -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);
});
});
+41
View File
@@ -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);
});
});
+165
View File
@@ -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);
});
});
+75
View File
@@ -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);
});
});
+28
View File
@@ -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);
});
});
+88
View File
@@ -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();
});
});
+88
View File
@@ -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));
});
});
+100
View File
@@ -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);
});
});
+110
View File
@@ -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 });
});
});
+110
View File
@@ -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);
});
});
+185
View File
@@ -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);
});
});
+129
View File
@@ -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);
});
});
+298
View File
@@ -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)
+119
View File
@@ -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 == []
+122
View File
@@ -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"
+86
View File
@@ -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()
+107
View File
@@ -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()
+140
View File
@@ -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
+85
View File
@@ -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
+127
View File
@@ -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()
+94
View File
@@ -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