use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
fn unique_test_dir(prefix: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let path = std::env::temp_dir().join(format!("tyler-{prefix}-{unique}"));
fs::create_dir_all(&path).expect("create test dir");
path
}
fn repo_path(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative)
}
fn read_fixture(relative: &str) -> String {
fs::read_to_string(repo_path(relative)).expect("read fixture")
}
fn write_ndjson_dataset(prefix: &str, metadata: &str, feature_blobs: &[String]) -> PathBuf {
let dataset = unique_test_dir(prefix);
let mut contents = String::new();
contents.push_str(metadata.trim_end());
contents.push('\n');
for feature_blob in feature_blobs {
contents.push_str(feature_blob.trim_end());
contents.push('\n');
}
fs::write(dataset.join("source.city.jsonl"), contents).expect("write ndjson source");
dataset
}
fn run_tyler(dataset: &Path, output: &Path, args: &[&str]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_tyler"));
command.arg(dataset).arg("--output").arg(output);
for arg in args {
command.arg(arg);
}
command.output().expect("run tyler")
}
fn read_json(path: &Path) -> Value {
serde_json::from_slice(&fs::read(path).expect("read json file")).expect("parse json file")
}
fn read_glb_json(bytes: &[u8]) -> Value {
assert!(bytes.len() >= 20, "glb should contain a header");
assert_eq!(&bytes[0..4], b"glTF");
assert_eq!(u32::from_le_bytes(bytes[4..8].try_into().unwrap()), 2);
let declared_length = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
assert_eq!(declared_length, bytes.len());
let json_length = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
assert_eq!(&bytes[16..20], b"JSON");
serde_json::from_slice(&bytes[20..20 + json_length]).expect("GLB JSON chunk should parse")
}
fn assert_success(output: &Output, context: &str) {
assert!(
output.status.success(),
"{context} failed: status={:?}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn collect_paths_with_suffix(dir: &Path, suffix: &str, out: &mut Vec<PathBuf>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_paths_with_suffix(&path, suffix, out);
} else if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(suffix))
{
out.push(path);
}
}
}
}
fn find_first_glb(dir: &Path) -> Option<PathBuf> {
for entry in fs::read_dir(dir).ok()? {
let entry = entry.ok()?;
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_first_glb(&path) {
return Some(found);
}
} else if path.extension().is_some_and(|extension| extension == "glb") {
return Some(path);
}
}
None
}
fn zero_grid_vertex_counts(path: &Path) {
let rewritten = fs::read_to_string(path)
.expect("read grid tsv")
.lines()
.enumerate()
.map(|(index, line)| {
if index <= 1 || line.trim().is_empty() {
return line.to_string();
}
let mut parts = line.splitn(3, '\t');
let cell_id = parts.next().expect("cell id");
let _nr_items = parts.next().expect("nr_items");
let wkt = parts.next().expect("wkt");
format!("{cell_id}\t0\t{wkt}")
})
.collect::<Vec<_>>()
.join("\n");
fs::write(path, format!("{rewritten}\n")).expect("rewrite grid tsv");
}
#[test]
fn debug_dump_data_writes_bincode_and_intermediary_cityjson() {
let metadata = read_fixture("resources/data/3dbag_x00.city.json");
let feature = read_fixture("resources/data/3dbag_feature_x71.city.jsonl");
let dataset = write_ndjson_dataset("debug-dump-data", &metadata, &[feature]);
let output_dir = unique_test_dir("debug-dump-data-output");
let output = run_tyler(&dataset, &output_dir, &["--debug-dump-data"]);
assert_success(&output, "debug dump data run");
let debug_dir = output_dir.join("debug");
assert!(debug_dir.join("world.bincode").is_file());
assert!(debug_dir.join("quadtree.bincode").is_file());
assert!(debug_dir.join("tiles_results.bincode").is_file());
let mut cityjson_inputs = Vec::new();
collect_paths_with_suffix(
&debug_dir.join("inputs"),
".city.jsonl",
&mut cityjson_inputs,
);
assert!(
!cityjson_inputs.is_empty(),
"expected intermediary CityJSON dumps under {}",
debug_dir.join("inputs").display()
);
}
#[test]
fn debug_dump_grid_and_grid_features_write_tsv_exports() {
let metadata = read_fixture("resources/data/3dbag_x00.city.json");
let feature = read_fixture("resources/data/3dbag_feature_x71.city.jsonl");
let dataset = write_ndjson_dataset("debug-dump-grid", &metadata, &[feature]);
let output_dir = unique_test_dir("debug-dump-grid-output");
let output = run_tyler(
&dataset,
&output_dir,
&[
"--debug-dump-grid",
"--debug-dump-grid-features",
"--qtree-capacity",
"1",
],
);
assert_success(&output, "debug dump grid run");
let debug_dir = output_dir.join("debug");
assert!(debug_dir.join("grid.tsv").is_file());
assert!(debug_dir.join("features.tsv").is_file());
assert!(debug_dir.join("quadtree_level-0.tsv").is_file());
assert!(debug_dir.join("tileset_level-0.tsv").is_file());
}
#[test]
fn debug_load_grid_uses_loaded_grid_for_quadtree_computation() {
let metadata = read_fixture("resources/data/3dbag_x00.city.json");
let feature = read_fixture("resources/data/3dbag_feature_x71.city.jsonl");
let dataset = write_ndjson_dataset("debug-load-grid", &metadata, &[feature]);
let seeded_output = unique_test_dir("debug-load-grid-seeded");
let output = run_tyler(
&dataset,
&seeded_output,
&[
"--debug-dump-grid",
"--debug-dump-grid-features",
"--qtree-capacity",
"1",
],
);
assert_success(&output, "seed grid dump run");
let seeded_tileset = read_json(&seeded_output.join("tileset.json"));
assert!(
seeded_tileset["root"]["children"].is_array(),
"baseline run should split the quadtree"
);
let replay_grid_dir = unique_test_dir("debug-load-grid-replay");
let replay_grid = replay_grid_dir.join("grid.tsv");
fs::copy(seeded_output.join("debug").join("grid.tsv"), &replay_grid).expect("copy grid");
fs::copy(
seeded_output.join("debug").join("features.tsv"),
replay_grid_dir.join("features.tsv"),
)
.expect("copy features");
zero_grid_vertex_counts(&replay_grid);
let replay_output = unique_test_dir("debug-load-grid-output");
let output = run_tyler(
&dataset,
&replay_output,
&[
"--debug-load-grid",
replay_grid.to_str().expect("utf8 replay grid path"),
"--debug-dump-grid",
"--qtree-capacity",
"1",
],
);
assert_success(&output, "debug load grid replay run");
let replay_tileset = read_json(&replay_output.join("tileset.json"));
assert!(
replay_tileset["root"].get("children").is_none(),
"loaded zero-count grid should collapse the quadtree to the root tile"
);
let mut quadtree_levels = Vec::new();
collect_paths_with_suffix(&replay_output.join("debug"), ".tsv", &mut quadtree_levels);
let quadtree_level_files = quadtree_levels
.into_iter()
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("quadtree_level-"))
})
.collect::<Vec<_>>();
assert_eq!(
quadtree_level_files.len(),
1,
"expected the loaded grid to produce a single-level quadtree"
);
}
#[test]
fn debug_3dtiles_tileset_only_skips_glb_conversion() {
let metadata = read_fixture("resources/data/3dbag_x00.city.json");
let features = read_fixture("cityjson-convert/tests/data/multi_feature_types.city.jsonl");
let dataset = write_ndjson_dataset("debug-tileset-only", &metadata, &[features]);
let output_dir = unique_test_dir("debug-tileset-only-output");
let output = run_tyler(
&dataset,
&output_dir,
&["--debug-3dtiles-tileset-only", "--qtree-capacity", "1"],
);
assert_success(&output, "tileset-only run");
assert!(output_dir.join("tileset.json").is_file());
assert!(
!output_dir.join("t").exists(),
"tileset-only mode should skip GLB tile output"
);
}
#[test]
fn object_attributes_filter_and_type_glb_metadata_schema() {
let metadata = read_fixture("resources/data/3dbag_x00.city.json");
let feature = serde_json::json!({
"type": "CityJSONFeature",
"id": "attribute-types",
"transform": {
"scale": [1.0, 1.0, 1.0],
"translate": [0.0, 0.0, 0.0]
},
"CityObjects": {
"building": {
"type": "Building",
"attributes": {
"as_text": 7,
"as_bool": "true",
"as_int": 9.0,
"as_float": 3,
"ignored": "drop-me"
},
"geometry": [{
"type": "MultiSurface",
"lod": "1",
"boundaries": [[[0, 1, 2], [0, 2, 3]]]
}]
}
},
"vertices": [
[0, 0, 0],
[4, 0, 0],
[4, 4, 0],
[0, 4, 0]
]
})
.to_string();
let dataset = write_ndjson_dataset("object-attributes", &metadata, &[feature]);
let output_dir = unique_test_dir("object-attributes-output");
let output = run_tyler(
&dataset,
&output_dir,
&[
"--object-type",
"Building",
"--object-attributes",
"as_text:string,as_bool:bool,as_int:int,as_float:float",
],
);
assert_success(&output, "object attributes run");
let glb_path = find_first_glb(&output_dir.join("t")).expect("expected at least one GLB");
let glb_json = read_glb_json(&fs::read(glb_path).expect("read glb"));
let properties = glb_json["extensions"]["EXT_structural_metadata"]["schema"]["classes"]
["citymodel"]["properties"]
.as_object()
.expect("structural metadata schema should exist");
assert_eq!(properties.len(), 4);
assert_eq!(properties["as_text"]["type"].as_str(), Some("STRING"));
assert_eq!(properties["as_bool"]["type"].as_str(), Some("SCALAR"));
assert_eq!(
properties["as_bool"]["componentType"].as_str(),
Some("INT8")
);
assert_eq!(
properties["as_int"]["componentType"].as_str(),
Some("INT32")
);
assert_eq!(
properties["as_float"]["componentType"].as_str(),
Some("FLOAT32")
);
assert!(!properties.contains_key("ignored"));
}
#[test]
fn object_type_building_single_tile_tileset_keeps_positive_root_geometric_error() {
let metadata = read_fixture("resources/data/3dbag_x00.city.json");
let feature = serde_json::json!({
"type": "CityJSONFeature",
"id": "single-building",
"transform": {
"scale": [1.0, 1.0, 1.0],
"translate": [0.0, 0.0, 0.0]
},
"CityObjects": {
"building": {
"type": "Building",
"attributes": {
"name": "Single building"
},
"geometry": [{
"type": "MultiSurface",
"lod": "1",
"boundaries": [[[0, 1, 2], [0, 2, 3]]]
}]
}
},
"vertices": [
[0, 0, 0],
[4, 0, 0],
[4, 4, 0],
[0, 4, 0]
]
})
.to_string();
let dataset = write_ndjson_dataset("object-type-building-single", &metadata, &[feature]);
let output_dir = unique_test_dir("object-type-building-single-output");
let output = run_tyler(&dataset, &output_dir, &["--object-type", "Building"]);
assert_success(&output, "single building tileset run");
let tileset = read_json(&output_dir.join("tileset.json"));
let root = &tileset["root"];
assert!(root["content"].is_object(), "root tile should keep content");
assert!(
root.get("children").is_none(),
"single-building dataset should produce a single root tile"
);
let root_geometric_error = root["geometricError"]
.as_f64()
.expect("root geometricError should be numeric");
assert!(
root_geometric_error.abs() <= f64::EPSILON,
"root tile geometricError can remain zero"
);
let tileset_geometric_error = tileset["geometricError"]
.as_f64()
.expect("tileset geometricError should be numeric");
assert!(
tileset_geometric_error > 0.0,
"tileset geometricError should stay positive for a single-tile tileset"
);
assert!(tileset_geometric_error > f64::EPSILON);
let unpruned_tileset = read_json(&output_dir.join("tileset_unpruned.json"));
let unpruned_root = &unpruned_tileset["root"];
assert!(unpruned_root["content"].is_object());
let unpruned_root_geometric_error = unpruned_root["geometricError"]
.as_f64()
.expect("unpruned root geometricError should be numeric");
assert!(
unpruned_root_geometric_error.abs() <= f64::EPSILON,
"unpruned root geometricError can remain zero"
);
let unpruned_tileset_geometric_error = unpruned_tileset["geometricError"]
.as_f64()
.expect("unpruned tileset geometricError should be numeric");
assert!(
unpruned_tileset_geometric_error > 0.0,
"unpruned tileset geometricError should stay positive"
);
assert!(unpruned_tileset_geometric_error > f64::EPSILON);
}