Files
gta6/tests/streaming.test.js
T
knso 77d53fb170 fix(extract): carriageway[].klasse als irreführendes Feld entfernt
Befund 6, Controller-Entscheidung: carriageway_area() vereinigt das ganze
Straßennetz zu wenigen Zusammenhangskomponenten, und _classified() gab
jeder Komponente die Klasse ihrer breitesten enthaltenen Straße - am
echten Netz trugen dadurch 91,2 % der Fahrbahnfläche "boulevard", auch wo
tatsächlich eine Wohnstraße lag. Kein Verbraucher las das Feld (Browser
und Minimap färben die Fahrbahn einheitlich); die Klasse je Straße steht
weiterhin in edges.

_classified() samt Aufruf aus run.py entfernt, chunk_payload() nimmt
carriageway jetzt als reine Liste von Polygonen statt (poly, klasse)-
Tupeln entgegen, Sortierschlüssel entsprechend auf poly allein verkürzt.
Fixtures in test_emit.py, test_overview.py, tests/geometry.test.js und
tests/streaming.test.js sowie das Chunk-Format im Entwurf nachgezogen;
tests/props.test.js bleibt unverändert, weil dort edges betroffen sind,
nicht carriageway.
2026-09-18 06:54:01 +02:00

311 lines
16 KiB
JavaScript
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.
import { afterEach, describe, expect, it, vi } from "vitest";
import { BufferGeometry, Group, Mesh } from "three";
import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js";
describe("wantedChunks", () => {
it("lädt die Umgebung des Spielers", () => {
const { add } = wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() });
expect(add.length).toBeGreaterThan(4);
expect(add.length).toBeLessThan(30);
});
it("entlädt erst jenseits des größeren Radius", () => {
// Chunk 5_5 hat sein Zentrum bei (375, -1125) (SLICE.x0 + 5.5*CHUNK_SIZE, ...).
// "near" liegt hier bewusst 500 Einheiten entfernt: außerhalb des Laderadius (400),
// aber innerhalb des Entladeradius (600) — genau die Hysterese, die der Testname
// behauptet. Der ursprüngliche Brief-Wert {x:1375, z:-1375} traf exakt das Zentrum
// von Chunk 9_4 (Distanz zu 5_5: ~1030, weit jenseits von 600) und ließ die eigene
// Behauptung "not.toContain" fehlschlagen — siehe Task-8-Report für den RED-Beleg.
const active = new Set(["5_5"]);
const near = wantedChunks({ x: 875, z: -1125 }, { load: 400, unload: 600, active });
expect(near.remove).not.toContain("5_5");
const far = wantedChunks({ x: -900, z: -2400 }, { load: 400, unload: 600, active });
expect(far.remove).toContain("5_5");
});
it("meldet nichts doppelt an", () => {
const active = new Set(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() }).add);
expect(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active }).add).toHaveLength(0);
});
});
describe("withoutReplacedBuildings", () => {
// Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250.
const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]];
const chunk = {
chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [],
buildings: [
{ poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060
{ poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150
],
};
// Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten
// stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert.
it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => {
const result = withoutReplacedBuildings(chunk, [[1405, 1055]]);
expect(result.buildings).toEqual([chunk.buildings[1]]);
expect(result.land).toBe(chunk.land);
expect(chunk.buildings).toHaveLength(2);
});
it("gibt ohne Treffer den Chunk selbst zurück", () => {
expect(withoutReplacedBuildings(chunk, [])).toBe(chunk);
expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk);
});
// Bruch: eine Zuordnung des Punkts per chunkForWorld ordnete ihn nur chunk_9_5 zu (i = 9) und ließe die
// ungeschnittene Kopie desselben Grundrisses in chunk_10_5 stehen — genau das Gebäude, das der Ort ersetzt.
it("lässt denselben, ungeschnittenen Grundriss in beiden berührten Chunks weg", () => {
const chunkA = {
chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [],
buildings: [{ poly: square(245, 190), height: 18.6 }], // Welt x 1495–1505, y 1050–1060
};
const chunkB = {
chunk: [10, 5], origin: [1500, -1250], land: [], surfaces: [], roads: [],
buildings: [{ poly: square(-5, 190), height: 18.6 }], // dieselbe Welt-Fläche, andere u
};
const points = [[1498, 1055]];
expect(withoutReplacedBuildings(chunkA, points).buildings).toEqual([]);
expect(withoutReplacedBuildings(chunkB, points).buildings).toEqual([]);
});
});
// Gemeinsame Vorrichtung für beide ChunkStreamer-describe-Blöcke unten (mit und ohne PropLayer).
// Realer Chunk ohne jede Geometrie — offenes Wasser. Entspricht wörtlich chunk_4_6.json
// (eines von vier: 4_6, 4_10, 5_6, 5_10 — per public/data/vice-a nachgezählt, Ruling 51).
const emptyChunk = { chunk: [4, 6], origin: [0, -1000], land: [], surfaces: [], roads: [], buildings: [] };
// Zentrum von Chunk 4_6 in three-Koordinaten: SLICE.x0 + 4.5*CHUNK_SIZE, -(SLICE.y1 - 6.5*CHUNK_SIZE).
const emptyChunkCenter = { x: 125, z: -875 };
function makeStreamer(chunkData, props, pinned, replacedBuildings) {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData })));
const player = {
addCollider: vi.fn(() => ({ id: 42 })),
removeCollider: vi.fn(),
// ready:false hier ist bewusst: ein echter (nicht-null) Handle wäre damit NIE bereit,
// sodass "isReadyAt sofort true" nur bestehen kann, wenn Ruling 51 tatsächlich greift
// und gar kein Handle erst registriert wird — sonst wäre der Test kein Regressionstest.
collisionWorld: { get: vi.fn(() => ({ ready: false })) },
};
const scene = { add: vi.fn(), remove: vi.fn() };
return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene };
}
// #load() ist privat und läuft nebenläufig; zwei Mikrotask-Runden reichen, um
// fetch() -> res.json() -> Geometrieaufbau vollständig abzuwarten (siehe #fetchChunk/#load).
const flush = () => new Promise((r) => setTimeout(r, 0)).then(() => new Promise((r) => setTimeout(r, 0)));
describe("ChunkStreamer", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("registriert für einen Chunk ganz ohne Kollisionsgeometrie keinen Collider (Ruling 51)", async () => {
const { streamer, player } = makeStreamer(emptyChunk);
streamer.update(emptyChunkCenter); // Zentrum -> garantiert der nächstliegende, zuerst geladene Chunk.
await flush();
expect(player.addCollider).not.toHaveBeenCalled();
expect(streamer.active.get("4_6")).toMatchObject({ handle: null });
});
it("meldet einen collider-losen Chunk sofort als bereit", async () => {
const { streamer } = makeStreamer(emptyChunk);
streamer.update(emptyChunkCenter);
await flush();
expect(streamer.isReadyAt(emptyChunkCenter)).toBe(true);
});
it("entlädt einen collider-losen Chunk, ohne removeCollider aufzurufen", async () => {
const { streamer, player } = makeStreamer(emptyChunk);
streamer.update(emptyChunkCenter);
await flush();
expect(streamer.active.has("4_6")).toBe(true);
// Weit weg -> jenseits des Entladeradius (600) -> #unload("4_6") im selben update().
streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z });
expect(player.removeCollider).not.toHaveBeenCalled();
expect(streamer.active.has("4_6")).toBe(false);
});
// Fix-Runde 2, Befund A / Ruling 62: this.palmGeometry, das Palmenmaterial und die
// Laternenteile entstehen einmal in PropLayer.load() und werden von jedem Chunk benutzt;
// #unload lief zuvor über die ganze Chunk-Gruppe und entsorgte sie mit, während
// Nachbarchunks sie noch anzeigten. mesh.userData.sharedGeometry markiert, was verschont
// bleibt — chunk-eigene Geometrie (hier: die Landfläche aus buildChunkGeometry) muss
// weiterhin entsorgt werden.
it("entsorgt beim Abmelden chunk-eigene Geometrie, aber keine als geteilt markierte (Ruling 62)", async () => {
const landChunk = {
chunk: [4, 6], origin: [0, -1000],
land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]],
surfaces: [], roads: [], buildings: [],
};
const sharedGeometry = new BufferGeometry();
const sharedDispose = vi.spyOn(sharedGeometry, "dispose");
const sharedMesh = new Mesh(sharedGeometry);
sharedMesh.userData.sharedGeometry = true;
const props = { build: () => { const visual = new Group(); visual.add(sharedMesh); return { visual, colliders: [] }; } };
const { streamer } = makeStreamer(landChunk, props);
streamer.update(emptyChunkCenter);
await flush();
// Chunk-eigene Geometrie (aus buildChunkGeometry, nicht als geteilt markiert) abgreifen
// und ihr dispose bespitzeln, bevor das Abmelden läuft.
const entry = streamer.active.get("4_6");
let ownGeometry;
entry.group.traverse((o) => {
if (o.isMesh && o !== sharedMesh && o.geometry && !ownGeometry) ownGeometry = o.geometry;
});
expect(ownGeometry).toBeTruthy(); // sonst testet dieser Test nichts
const ownDispose = vi.spyOn(ownGeometry, "dispose");
// Weit weg -> jenseits des Entladeradius (600) -> #unload("4_6") im selben update().
streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z });
expect(ownDispose).toHaveBeenCalledTimes(1);
expect(sharedDispose).not.toHaveBeenCalled();
});
// Task 11, Schritt 5: die vier Fahrzeug-Chunks werden festgehalten, weil ein Fahrzeug
// ohne Boden ins Nichts fällt — #unload muss sie überspringen, statt sie beim
// gewöhnlichen Entfernen-jenseits-des-Radius-Durchlauf trotzdem abzubauen. Mit echter
// Landgeometrie (nicht emptyChunk), damit ein echtes Collider-Handle entsteht und die
// removeCollider-Zusicherung unten etwas prüft, statt nach Ruling 51 trivial wahr zu sein.
it("entlädt einen angehefteten Chunk nicht, auch jenseits des Entladeradius (Task 11)", async () => {
const landChunk = {
chunk: [4, 6], origin: [0, -1000],
land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]],
surfaces: [], roads: [], buildings: [],
};
const { streamer, player } = makeStreamer(landChunk, undefined, ["4_6"]);
streamer.update(emptyChunkCenter);
await flush();
expect(streamer.active.has("4_6")).toBe(true);
expect(player.addCollider).toHaveBeenCalledTimes(1); // sonst prüft removeCollider unten nichts
// Weit weg -> jenseits des Entladeradius (600); ohne pinned wäre "4_6" nun weg
// (siehe Test oben, gleiches Setup ohne pinned).
streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z });
expect(streamer.active.has("4_6")).toBe(true);
expect(player.removeCollider).not.toHaveBeenCalled();
});
// Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt
// Kollision mitten in dem Ort, der ihn ersetzt.
it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => {
const chunk = {
chunk: [4, 6], origin: [0, -1000],
land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]],
surfaces: [], roads: [],
buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }],
};
// Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe.
const plain = makeStreamer(chunk);
plain.streamer.update(emptyChunkCenter);
await flush();
expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2);
// Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985.
const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]);
streamer.update(emptyChunkCenter);
await flush();
const source = player.addCollider.mock.calls[0][0].shape.source;
expect(source.children).toHaveLength(1);
expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude
});
// Bruch: eine neue Schicht fehlt in DECALS — Weg oder Feld landete in der Kollision und die Kapsel stolperte
// über 2–3 cm hohe Kanten, oder die Schicht fehlte ganz in der Szene.
it("legt Parkwege und die neuen Flächenarten als Auflagen ohne Kollision in die Szene", async () => {
const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]];
const chunk = {
chunk: [4, 6], origin: [0, -1000], land: [square], roads: [], buildings: [],
surfaces: ["field", "court", "pool", "parking"].map((kind) => ({ poly: square, kind })),
paths: [{ poly: square }],
};
const { streamer, player, scene } = makeStreamer(chunk);
streamer.update(emptyChunkCenter);
await flush();
expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(1); // nur die Landplatte
const group = scene.add.mock.calls[0][0];
expect(group.children.filter((child) => child.isMesh)).toHaveLength(5);
});
// Teil 2: Fahrbahn und Bordstein sind Kollision — man fährt in der Mulde, und der Bordstein ist die
// Stufe zum Gehweg. Gehweg, Markierung und Restflächen bleiben Auflagen.
it("legt Fahrbahn und Bordstein in die Kollision, Gehweg, Markierung und Restfläche als Auflagen in die Szene", async () => {
const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]];
const chunk = {
chunk: [4, 6], origin: [0, -1000], land: [square], surfaces: [], buildings: [],
carriageway: [{ poly: [[[0, 20], [20, 20], [20, 30], [0, 30]]] }],
curb: [[[0, 20], [20, 20]]],
sidewalk: [{ poly: [[[0, 17], [20, 17], [20, 20], [0, 20]]] }],
markings: [{ poly: [[[2, 25], [5, 25], [5, 25.1], [2, 25.1]]], art: "gelb_gestrichelt" }],
lots: [{ poly: [[[0, 0], [5, 0], [5, 5], [0, 5]]] }],
};
const { streamer, player, scene } = makeStreamer(chunk);
streamer.update(emptyChunkCenter);
await flush();
expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(3); // Landplatte, Fahrbahn, Bordstein
const group = scene.add.mock.calls[0][0];
expect(group.children.filter((child) => child.isMesh)).toHaveLength(3); // Gehweg, Markierung, Restfläche
});
});
// Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine
// automatische Abdeckung — nur von Hand belegt (Entfernen -> drei Tests fallen), siehe
// Task-10-Bericht. Ein Platzhalter-PropLayer im Konstruktor deckt das jetzt ab.
describe("ChunkStreamer mit PropLayer", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("ruft props.build() je geladenem Chunk einmal auf und hängt sein visual in die Szene", async () => {
const propsVisual = new Group();
const props = { build: vi.fn(() => ({ visual: propsVisual, colliders: [] })) };
const { streamer, scene } = makeStreamer(emptyChunk, props);
streamer.update(emptyChunkCenter);
await flush();
expect(props.build).toHaveBeenCalledTimes(1);
expect(props.build).toHaveBeenCalledWith(emptyChunk);
expect(scene.add).toHaveBeenCalledTimes(1);
const sceneGroup = scene.add.mock.calls[0][0];
expect(sceneGroup.children).toContain(propsVisual);
});
it("hängt colliders von props.build() in dieselbe BVH-Gruppe wie die Chunk-Kollision", async () => {
const trunk = new Mesh();
const props = { build: vi.fn(() => ({ visual: new Group(), colliders: [trunk] })) };
const { streamer, player } = makeStreamer(emptyChunk, props);
streamer.update(emptyChunkCenter);
await flush();
// emptyChunk hat sonst keine Kollisionsgeometrie (Ruling 51) — der Stamm allein
// macht den Chunk-Collider nicht-leer, addCollider muss also aufgerufen werden.
expect(player.addCollider).toHaveBeenCalledTimes(1);
const source = player.addCollider.mock.calls[0][0].shape.source;
expect(source.children).toContain(trunk);
});
it("läuft ohne props weiterhin fehlerfrei (props ist seit Task 8 optional)", async () => {
const { streamer } = makeStreamer(emptyChunk /* kein props */);
streamer.update(emptyChunkCenter);
await flush();
expect(streamer.active.get("4_6")).toMatchObject({ handle: null });
});
});