Files
geograsim/App/sims/logistik/test.html
T
Adminator 3885c83294 Atlas: Logistik-Gerüst + Heli-Fix + Waypoints-Regen + Level-Picker
- Neues Modul 'Logistik Europa': module_info-Eintrag, Landing-Card,
  PHP-Wrapper (logistik.php, modul-logistik.php), Engine-Skelett mit
  Enums + Helpern (travelCost, latePenalty, Bonus, Dijkstra,
  Polyline-Interpolation), Test-Harness, Kompetenzen-Draft,
  5 Seed-Dateien (locations, vehicle-types, cargo-types, railnet,
  contract-templates), 3 Level-Einträge in game_levels, Phase 0 durch
  die Logistik-Instanz geliefert und Atlas-Review bestanden
- Heli-Fix: heli-game.php mit BASE_PATH (production-sicher) +
  Asset-Pfad-Injection, geo_waypoints-Tabelle per Seed-Script
  auffindbar gemacht
- Waypoints-Regen-Tool: regen-waypoints-sql.php synchronisiert
  waypoints.sql aus DB (70 Einträge)
- Design-System: .ggs-level-grid / .ggs-level-card als Standard
  gepromotet (Klimas Muster, iPad-hover-safe)
- Inbox-Nachrichten: Kickoff-Briefings für Heli und Logistik,
  Asset-Map für Heli, DALL-E-Pipeline-Anleitung, Koordinations-
  nachrichten an Fluss, Klima, Glossar, Lehrplan

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:43:48 +02:00

292 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Logistik Europa — Test-Harness</title>
<style>
body { font-family: 'Segoe UI', system-ui, sans-serif; max-width: 960px; margin: 24px auto; padding: 0 16px; color: #222; background: #f7f5ef; }
h1 { font-size: 1.4rem; margin-bottom: 8px; }
.meta { color: #666; font-size: 0.9rem; margin-bottom: 20px; }
.group { background: #fff; border: 1px solid #e2ddd1; border-radius: 8px; padding: 14px 18px; margin: 14px 0; box-shadow: 0 2px 6px rgba(0,0,0,.04); }
.group h2 { font-size: 1.05rem; margin: 0 0 10px; color: #1f4b37; }
.case { display: grid; grid-template-columns: 20px 1fr; gap: 10px; padding: 4px 0; font-size: 0.9rem; }
.pass { color: #2d6d3d; }
.fail { color: #b32d2d; }
.case code { background: #f0ece3; padding: 1px 4px; border-radius: 3px; font-size: 0.85em; }
.summary { margin-top: 20px; padding: 12px; background: #1f4b37; color: #fff; border-radius: 6px; font-weight: 600; }
.summary.fail { background: #b32d2d; }
</style>
</head>
<body>
<h1>Logistik Europa — Test-Harness</h1>
<p class="meta">Pflichttests aus dem Kickoff-Briefing (Phase 0). Läuft ohne Test-Runner-Pipeline — einfach im Browser öffnen.</p>
<div id="results"></div>
<div id="summary" class="summary">…läuft…</div>
<script src="engine.js"></script>
<script src="headless-runner.js"></script>
<script>
(async function () {
const groups = [];
const pending = [];
let pass = 0, fail = 0;
function group(title, fn) {
const g = { title, cases: [] };
groups.push(g);
const t = {
eq: (actual, expected, label) => {
const ok = _deepEq(actual, expected);
g.cases.push({ ok, label, actual, expected });
ok ? pass++ : fail++;
},
approx: (actual, expected, tol, label) => {
const ok = Math.abs(actual - expected) <= tol;
g.cases.push({ ok, label, actual, expected: `${expected} ±${tol}` });
ok ? pass++ : fail++;
},
truthy: (actual, label) => {
const ok = !!actual;
g.cases.push({ ok, label, actual, expected: 'truthy' });
ok ? pass++ : fail++;
},
};
// fn kann sync oder async sein — beides unterstützen.
const job = (async () => {
try { await fn(t); }
catch (e) {
g.cases.push({ ok: false, label: 'Exception', actual: e.message, expected: '—' });
fail++;
}
})();
pending.push(job);
}
function _deepEq(a, b) {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a && b && typeof a === 'object') {
const ka = Object.keys(a), kb = Object.keys(b);
if (ka.length !== kb.length) return false;
return ka.every(k => _deepEq(a[k], b[k]));
}
return false;
}
/* =========================================================
PFLICHTTESTS 17 aus Kickoff-Briefing §5.3
========================================================= */
group('Test 1 — Fahrkosten (travelCost)', (t) => {
// Kleiner LKW: 300 km × 1.2 €/km + 4.5 h × 25 €/h = 360 + 112.5 = 472.5 €
t.approx(LogistikEngine.travelCost(300, 4.5, 'TRUCK_SMALL'), 472.5, 0.01, '300 km, 4.5 h, TRUCK_SMALL → 472.50 €');
t.approx(LogistikEngine.travelCost(800, 12, 'TRUCK_LARGE'), 800 * 2.5 + 12 * 45, 0.01, '800 km, 12 h, TRUCK_LARGE');
t.approx(LogistikEngine.travelCost(950, 10.5, 'TRAIN'), 950 * 8 + 10.5 * 150, 0.01, '950 km, 10.5 h, TRAIN');
});
group('Test 2 — Route-Interpolation', (t) => {
const line = [[48.20, 16.37], [53.55, 9.97]]; // Wien → Hamburg (Luftlinie)
const mid = LogistikEngine.interpolateAlongPolyline(line, 0.5);
t.approx(mid[0], (48.20 + 53.55) / 2, 0.5, 'Mittelpunkt lat ≈ Schnittmittel');
t.approx(mid[1], (16.37 + 9.97) / 2, 0.5, 'Mittelpunkt lon ≈ Schnittmittel');
const start = LogistikEngine.interpolateAlongPolyline(line, 0);
t.eq(start, line[0], 'progress=0 → Startpunkt');
const end = LogistikEngine.interpolateAlongPolyline(line, 1);
t.eq(end, line[1], 'progress=1 → Endpunkt');
});
group('Test 3 — Strafkosten Verspätung (latePenalty)', (t) => {
// 1000 € Auftrag, 2 h Verspätung → 20% × 2 = 400 €
t.approx(LogistikEngine.latePenalty(1000, 2), 400, 0.01, '1000 €, 2 h spät → 400 €');
t.approx(LogistikEngine.latePenalty(1500, 0.5), 150, 0.01, '1500 €, 0.5 h spät → 150 €');
t.approx(LogistikEngine.latePenalty(1000, 0), 0, 0.01, 'Keine Verspätung → 0 €');
});
group('Test 4 — Bonus-Kombi (calculateBonus)', (t) => {
// 1000 €, pünktlich (+10%) + optimal (+20%) = 300 €
t.approx(LogistikEngine.calculateBonus(1000, { onTime: true, noEmpty: true }), 300, 0.01, 'pünktlich + optimal → +30%');
t.approx(LogistikEngine.calculateBonus(1000, { onTime: true, noEmpty: false }), 100, 0.01, 'nur pünktlich → +10%');
t.approx(LogistikEngine.calculateBonus(1000, { onTime: false, noEmpty: true }), 200, 0.01, 'nur optimal → +20%');
t.approx(LogistikEngine.calculateBonus(1000, { onTime: false, noEmpty: false }), 0, 0.01, 'nichts → 0');
});
group('Test 5 — Event-Auswirkung (Phase 5 — skip bis implementiert)', (t) => {
// Placeholder: echte Tests kommen mit Phase-5-Implementation
t.truthy(LogistikEngine.EVENT_RULES.TRAFFIC_ACCIDENT, 'EVENT_RULES.TRAFFIC_ACCIDENT definiert');
t.eq(LogistikEngine.EVENT_RULES.TRAFFIC_ACCIDENT.speedMult, 0.5, 'Unfall halbiert Geschwindigkeit');
});
group('Test 6 — Savegame-Roundtrip (Phase 2 — skip bis implementiert)', (t) => {
// Placeholder: echte Tests mit serialize/deserialize in Phase 2
const game = LogistikEngine.createGame(1);
const json = JSON.stringify(game);
const restored = JSON.parse(json);
t.eq(restored.levelId, game.levelId, 'Roundtrip levelId');
t.eq(restored.balance, game.balance, 'Roundtrip balance');
t.eq(restored.state, game.state, 'Roundtrip state');
});
group('Test 7 — Bahn-Dijkstra (railShortestPath)', (t) => {
// WienMünchenHamburg: 400 + 800 = 1200 km
const graph = {
nodes: [{id:'wien'},{id:'muenchen'},{id:'hamburg'},{id:'rotterdam'},{id:'paris'}],
edges: [
{ from:'wien', to:'muenchen', distanceKm: 400 },
{ from:'muenchen', to:'hamburg', distanceKm: 800 },
{ from:'hamburg', to:'rotterdam', distanceKm: 500 },
],
};
const result = LogistikEngine.railShortestPath(graph, 'wien', 'hamburg');
t.truthy(result, 'Pfad gefunden');
t.eq(result.distanceKm, 1200, 'Wien → Hamburg: 1200 km');
t.eq(result.path, ['wien', 'muenchen', 'hamburg'], 'Pfad über München');
});
/* =========================================================
EDGE-CASES (Atlas-Auflage Phase 0)
========================================================= */
group('Test 8 — Edge-Cases', (t) => {
// 8.1 Leere Polyline → Exception
let threw = false;
try { LogistikEngine.interpolateAlongPolyline([], 0.5); }
catch (e) { threw = true; }
t.truthy(threw, 'leere Polyline → Exception');
// 8.2 Polyline mit nur einem Punkt → Exception
threw = false;
try { LogistikEngine.interpolateAlongPolyline([[0, 0]], 0.5); }
catch (e) { threw = true; }
t.truthy(threw, '1-Punkt-Polyline → Exception');
// 8.3 Dijkstra ohne Verbindung → null
const isolated = {
nodes: [{id:'a'},{id:'b'},{id:'inselA'},{id:'inselB'}],
edges: [
{ from:'a', to:'b', distanceKm: 100 },
{ from:'inselA', to:'inselB', distanceKm: 50 },
],
};
t.eq(LogistikEngine.railShortestPath(isolated, 'a', 'inselB'), null,
'getrennte Komponenten → null');
// 8.4 Dijkstra zum Selbst → distanceKm = 0
const r = LogistikEngine.railShortestPath(isolated, 'a', 'a');
t.truthy(r, 'Pfad zum Selbst gefunden');
t.eq(r.distanceKm, 0, 'Pfad zum Selbst: 0 km');
// 8.5 latePenalty mit negativen Stunden → 0 (keine negative Strafe)
t.eq(LogistikEngine.latePenalty(1000, -2), 0, 'negative Stunden → 0');
// 8.6 latePenalty mit null Wert → 0
t.eq(LogistikEngine.latePenalty(0, 5), 0, 'null Auftragswert → 0');
// 8.7 travelCost mit unbekanntem Modus → Exception
threw = false;
try { LogistikEngine.travelCost(100, 1, 'HELI'); }
catch (e) { threw = true; }
t.truthy(threw, 'unbekannter Modus → Exception');
// 8.8 calculateBonus mit beiden Flags false → 0
t.eq(LogistikEngine.calculateBonus(1000, { onTime: false, noEmpty: false }), 0,
'keine Flags → kein Bonus');
});
/* =========================================================
SEEDED-RANDOM (Phase 0 — Reproduzierbarkeit)
========================================================= */
group('Test 9 — Seeded-Random (Mulberry32)', (t) => {
t.truthy(typeof LogistikRunner !== 'undefined', 'LogistikRunner geladen');
t.truthy(typeof LogistikRunner.makeRng === 'function', 'makeRng verfügbar');
// 9.1 Gleicher Seed → identische Sequenz
const a = LogistikRunner.makeRng(42);
const b = LogistikRunner.makeRng(42);
const seqA = Array.from({length: 10}, () => a());
const seqB = Array.from({length: 10}, () => b());
t.eq(seqA, seqB, 'Seed 42 → identische Sequenz auf zwei Instanzen');
// 9.2 Unterschiedliche Seeds → unterschiedliche Sequenzen
const c = LogistikRunner.makeRng(99);
const seqC = Array.from({length: 10}, () => c());
let differs = false;
for (let i = 0; i < 10; i++) if (seqA[i] !== seqC[i]) { differs = true; break; }
t.truthy(differs, 'Seed 42 ≠ Seed 99');
// 9.3 Werte im Bereich [0, 1)
t.truthy(seqA.every(v => v >= 0 && v < 1), 'alle Werte in [0, 1)');
// 9.4 Seed 0 funktioniert (wird intern auf 1 normalisiert, kein NaN)
const z = LogistikRunner.makeRng(0);
const v0 = z();
t.truthy(typeof v0 === 'number' && !isNaN(v0), 'Seed 0 liefert valide Zahl');
});
/* =========================================================
RUNNER-VERTRAG (Phase 0 — noop läuft, andere stubben)
========================================================= */
group('Test 10 — Headless-Runner-Vertrag (noop)', async (t) => {
// 10.1 noop-Strategie liefert vollständiges Result
const r = await LogistikRunner.runLevel(1, 1, 'noop');
t.truthy(r, 'runLevel liefert Ergebnis');
t.eq(r.successful, false, 'noop bei L1 → success=false');
t.eq(r.completedContracts, 0, 'noop schließt keine Aufträge ab');
t.eq(r.lateDeliveries, 0, 'noop hat keine Verspätungen');
t.truthy(r.durationHours > 0, 'durationHours > 0');
t.eq(r.strategy, 'noop', 'strategy-Feld gesetzt');
t.eq(r.level, 1, 'level-Feld gesetzt');
t.eq(r.seed, 1, 'seed-Feld gesetzt');
t.truthy(Array.isArray(r.log), 'log ist Array');
// 10.2 Reproduzierbar: gleicher Seed → identisches Ergebnis
const r2 = await LogistikRunner.runLevel(1, 1, 'noop');
t.eq(r.endBalance, r2.endBalance, 'Seed 1 reproduziert Balance');
t.eq(r.durationHours, r2.durationHours, 'Seed 1 reproduziert Dauer');
t.eq(r.log.length, r2.log.length, 'Seed 1 reproduziert log-Länge');
// 10.3 naive/greedy/optimal werfen kontrolliert (Phase 2)
let naiveThrew = false;
try { await LogistikRunner.runLevel(1, 1, 'naive'); }
catch (e) { naiveThrew = /Phase 2/.test(e.message); }
t.truthy(naiveThrew, 'naive wirft kontrolliert "Phase 2"');
// 10.4 Unbekannte Strategie wirft
let unknownThrew = false;
try { await LogistikRunner.runLevel(1, 1, 'fantasy'); }
catch (e) { unknownThrew = true; }
t.truthy(unknownThrew, 'unbekannte Strategie → Exception');
});
/* =========================================================
RENDER (wartet auf alle async-Tests)
========================================================= */
await Promise.all(pending);
const root = document.getElementById('results');
groups.forEach(g => {
const box = document.createElement('div');
box.className = 'group';
box.innerHTML = '<h2>' + g.title + '</h2>';
g.cases.forEach(c => {
const row = document.createElement('div');
row.className = 'case';
row.innerHTML = '<span class="' + (c.ok ? 'pass' : 'fail') + '">' + (c.ok ? '✓' : '✗') + '</span>' +
'<span>' + c.label + (c.ok ? '' : '<br><code>got: ' + JSON.stringify(c.actual) + ' / expected: ' + JSON.stringify(c.expected) + '</code>') + '</span>';
box.appendChild(row);
});
root.appendChild(box);
});
const sum = document.getElementById('summary');
sum.textContent = pass + ' bestanden, ' + fail + ' fehlgeschlagen';
sum.className = 'summary' + (fail ? ' fail' : '');
})();
</script>
</body>
</html>