import { afterEach, describe, expect, it, vi } from "vitest"; import { ChunkStreamer, wantedChunks } 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("ChunkStreamer", () => { // 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 }; afterEach(() => { vi.unstubAllGlobals(); }); function makeStreamer(chunkData) { 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: {} }), 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))); 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); }); });