import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { gzipSync, gunzipSync } from 'node:zlib'; import { createHash } from 'node:crypto'; import { assembleWorld, parseManifest, parseTile, tileWorld } from '../src/city/format'; import { TileLoader, fetchJson } from '../client/tiles'; import { exportCity } from '../tools/export/index'; import type { World } from '../src/types'; function fixture(): World { return { version: 1, id: 'test', name: 'Test', createdAt: '2026-10-30T00:00:00Z', seed: 1, origin: { lat: 0, lon: 0 }, bbox: [0, 0, 1, 1], bounds: { minX: -128, minY: -128, maxX: 512, maxY: 512 }, attribution: 'OSM', areas: [{ id: 'area', kind: 'park', rings: [[[-20, -20], [20, -20], [20, 20], [-20, 20]]] }], roads: [{ id: 'road', kind: 'residential', pts: [[-10, 10], [300, 10]], width: 6, sidewalk: 2, lanes: 2, oneway: false, layer: 0, bridge: false, tunnel: false }], buildings: [{ id: 'building', kind: 'house', footprint: [[250, 20], [270, 20], [270, 40], [250, 40]], holes: [], area: 400, levels: 1, levelsSource: 'osm', frame: { origin: [250, 20], angle: 0 }, beds: 1, seed: 1, units: [], floors: [{ level: 0, rooms: [], walls: [{ a: [250, 20], b: [270, 20], thickness: 0.2, exterior: true }], doors: [], windows: [], stairs: [], props: [{ id: 'bed', kind: 'bed', pos: [260, 30], rot: 0, w: 2, d: 1, blocksMove: true, blocksSight: false, model: 'bed-model' }] }] }], props: [{ id: 'tree', kind: 'tree', pos: [-1, -1], w: 4, d: 4, rot: 0, blocksMove: true, blocksSight: false }], models: { 'bed-model': [{ t: 'rect', x: 0, y: 0, w: 2, h: 1 }] } }; } test('tile roundtrip preserves full interiors and model references without duplicate objects', () => { const world = fixture(); const { manifest, tiles } = tileWorld(world); assert.ok(tiles.has('-1_-1')); assert.equal(tiles.get('0_0')!.buildings[0].id, 'building'); assert.equal(tiles.get('1_0')!.buildings[0].id, 'building'); const restored = assembleWorld(parseManifest(JSON.parse(JSON.stringify(manifest))), [...tiles].map(([id, tile]) => parseTile(JSON.parse(JSON.stringify(tile)), world.id, id))); assert.deepEqual(JSON.parse(JSON.stringify(restored)), world); assert.deepEqual(tileWorld(world), tileWorld(world)); }); test('rejects incompatible versions, paths, duplicate IDs and invalid grids', () => { const { manifest, tiles } = tileWorld(fixture()); assert.throws(() => parseManifest({ ...manifest, formatVersion: 99 }), /version/); assert.throws(() => parseManifest({ ...manifest, tiles: [{ ...manifest.tiles[0], url: '../secret' }] }), /reference/); assert.throws(() => parseManifest({ ...manifest, tiles: [manifest.tiles[0], manifest.tiles[0]] }), /reference/); assert.throws(() => tileWorld(fixture(), 0)); assert.throws(() => tileWorld(fixture(), NaN)); const tile = [...tiles.values()][0]; assert.throws(() => parseTile(tile, 'wrong-city', tile.id)); assert.throws(() => tileWorld({ ...fixture(), models: {} }), /Missing model/); }); test('area layering is stable regardless of tile arrival order',()=>{ const world=fixture();world.areas.push({id:'grass',kind:'grass',rings:[[[300,0],[310,0],[310,10],[300,10]]]}); const {manifest,tiles}=tileWorld(world); assert.deepEqual(assembleWorld(manifest,[...tiles.values()].reverse()).areas.map(a=>a.id),world.areas.map(a=>a.id)); }); test('verifies SHA-256 in browsers without secure-context WebCrypto',async()=>{ const originalFetch=globalThis.fetch;const descriptor=Object.getOwnPropertyDescriptor(globalThis,'crypto'); const json=JSON.stringify({name:'Бальцерс'}); try { Object.defineProperty(globalThis,'crypto',{value:undefined,configurable:true}); globalThis.fetch=async()=>new Response(json); assert.deepEqual(await fetchJson('http://lan/tile',undefined,createHash('sha256').update(json).digest('hex')),{name:'Бальцерс'}); await assert.rejects(fetchJson('http://lan/tile',undefined,'0'.repeat(64)),/checksum/); } finally {globalThis.fetch=originalFetch;if(descriptor)Object.defineProperty(globalThis,'crypto',descriptor);} }); test('export produces readable gzip files and a complete manifest', async () => { const dir = await mkdtemp(join(tmpdir(), 'citygen-export-')); try { const manifest = await exportCity(fixture(), dir); await assert.rejects(exportCity(fixture(),dir),/immutable/); assert.deepEqual(JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8')), manifest); const navigation=JSON.parse(gunzipSync(await readFile(join(dir,manifest.navigation!.url))).toString()); assert.equal(navigation.roads[0].id,'road'); for (const ref of manifest.tiles) { const value = JSON.parse(gunzipSync(await readFile(join(dir, ref.url))).toString()); assert.equal(parseTile(value, manifest.city.id, ref.id).id, ref.id); } } finally { await rm(dir, { recursive: true, force: true }); } }); test('HTTP loader selects AOI, handles gzip headers or files, caches and evicts', async () => { const { manifest, tiles } = tileWorld(fixture()); const original = globalThis.fetch; const calls: string[] = []; globalThis.fetch = async input => { const url = String(input); calls.push(url); const id = url.split('/').pop()!.replace('.json.gz', ''); return new Response(gzipSync(JSON.stringify(tiles.get(id)))); }; try { const loader = new TileLoader(manifest, 'http://test/city/manifest.json', 1); const aoi = { minX: 30, minY: 30, maxX: 40, maxY: 40 }; const world = await loader.load(aoi); assert.equal(calls.length, 1); assert.equal(world.buildings[0].id, 'building'); await loader.load(aoi); assert.equal(calls.length, 1); await loader.load({ minX: -10, minY: -10, maxX: -5, maxY: -5 }); await loader.load(aoi); assert.equal(calls.length, 3); await assert.rejects(loader.load(manifest.city.bounds), /Приблизьте/); loader.destroy(); globalThis.fetch = async () => new Response(JSON.stringify({ ok: true })); assert.deepEqual(await fetchJson('http://test/plain'), { ok: true }); await assert.rejects(fetchJson('http://test/plain',undefined,'0'.repeat(64)),/checksum/); globalThis.fetch = async () => new Response('missing', { status: 404 }); await assert.rejects(fetchJson('http://test/missing'), /404/); } finally { globalThis.fetch = original; } }); test('superseded requests never replace a newer viewport', async () => { const { manifest, tiles } = tileWorld(fixture()); const original = globalThis.fetch; let finish!: () => void; let first = true; globalThis.fetch = async input => { const id = String(input).split('/').pop()!.replace('.json.gz', ''); if (first) { first = false; await new Promise(resolve => { finish = resolve; }); } return new Response(JSON.stringify(tiles.get(id))); }; try { const loader = new TileLoader(manifest, 'http://test/city/manifest.json'); const old = loader.load({ minX: 30, minY: 30, maxX: 40, maxY: 40 }); await loader.load({ minX: -10, minY: -10, maxX: -5, maxY: -5 }); finish(); await assert.rejects(old, { name: 'AbortError' }); loader.destroy(); } finally { globalThis.fetch = original; } });