Files

129 lines
7.2 KiB
TypeScript

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<void>(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; }
});