221 lines
7.6 KiB
Python
221 lines
7.6 KiB
Python
"""Tests for the lightweight glTF metadata extraction service."""
|
|
|
|
import json
|
|
import struct
|
|
import pytest
|
|
|
|
from app.services.gltf import (
|
|
extract_gltf_metadata,
|
|
looks_like_gltf,
|
|
_parse_glb,
|
|
_parse_gltf_json,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test data helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_gltf_json(
|
|
meshes: int = 1,
|
|
vertices_per_mesh: int = 24,
|
|
has_animations: bool = False,
|
|
images: list[dict] | None = None,
|
|
) -> dict:
|
|
"""Build a minimal glTF JSON structure."""
|
|
accessors = []
|
|
meshes_data = []
|
|
for i in range(meshes):
|
|
acc_index = len(accessors)
|
|
accessors.append({
|
|
"bufferView": 0,
|
|
"componentType": 5126, # FLOAT
|
|
"count": vertices_per_mesh,
|
|
"type": "VEC3",
|
|
"max": [1.0, 1.0, 1.0],
|
|
"min": [-1.0, -1.0, -1.0],
|
|
})
|
|
meshes_data.append({
|
|
"primitives": [{
|
|
"attributes": {"POSITION": acc_index},
|
|
}],
|
|
})
|
|
gltf = {
|
|
"asset": {"version": "2.0"},
|
|
"accessors": accessors,
|
|
"meshes": meshes_data,
|
|
}
|
|
if has_animations:
|
|
gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}]
|
|
if images:
|
|
gltf["images"] = images
|
|
return gltf
|
|
|
|
|
|
def _make_glb(gltf_json: dict) -> bytes:
|
|
"""Wrap *gltf_json* in a minimal GLB binary container."""
|
|
json_bytes = json.dumps(gltf_json).encode("utf-8")
|
|
json_length = len(json_bytes)
|
|
# Pad JSON chunk to 4-byte boundary.
|
|
json_padding = (4 - json_length % 4) % 4
|
|
json_chunk = json_bytes + b"\x00" * json_padding
|
|
json_chunk_type = 0x4E4F534A # "JSON"
|
|
# No BIN chunk.
|
|
bin_length = 0
|
|
bin_chunk_type = 0x004E4942
|
|
total_length = 12 + 8 + len(json_chunk) + 8 + bin_length
|
|
header = struct.pack("<III", 0x46546C67, 2, total_length) # magic, version, length
|
|
json_header = struct.pack("<II", len(json_chunk), json_chunk_type)
|
|
if bin_length > 0:
|
|
bin_header = struct.pack("<II", bin_length, bin_chunk_type)
|
|
else:
|
|
bin_header = b""
|
|
return header + json_header + json_chunk + bin_header
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# looks_like_gltf
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLooksLikeGltf:
|
|
def test_glb_magic(self):
|
|
assert looks_like_gltf(b"glTF\x00\x00\x00\x00") is True
|
|
|
|
def test_gltf_extension(self):
|
|
assert looks_like_gltf(b'{"asset":{"version":"2.0"}}', "model.gltf") is True
|
|
|
|
def test_glb_extension(self):
|
|
assert looks_like_gltf(b"some binary", "model.glb") is True
|
|
|
|
def test_json_with_asset_version(self):
|
|
payload = json.dumps({"asset": {"version": "2.0"}}).encode()
|
|
assert looks_like_gltf(payload) is True
|
|
|
|
def test_plain_json_no_asset(self):
|
|
payload = json.dumps({"foo": "bar"}).encode()
|
|
assert looks_like_gltf(payload) is False
|
|
|
|
def test_non_json_bytes(self):
|
|
assert looks_like_gltf(b"\x89PNG\r\n\x1a\n") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_glb
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseGlb:
|
|
def test_parses_valid_glb(self):
|
|
gltf_json = _make_gltf_json()
|
|
glb = _make_glb(gltf_json)
|
|
result = _parse_glb(glb)
|
|
assert result["asset"]["version"] == "2.0"
|
|
assert len(result["meshes"]) == 1
|
|
|
|
def test_rejects_too_short(self):
|
|
with pytest.raises(ValueError, match="too short"):
|
|
_parse_glb(b"glTF\x00\x00")
|
|
|
|
def test_rejects_invalid_magic(self):
|
|
with pytest.raises(ValueError, match="invalid GLB magic"):
|
|
_parse_glb(b"NOTglTF\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_gltf_json
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestParseGltfJson:
|
|
def test_parses_valid_gltf(self):
|
|
gltf_json = _make_gltf_json()
|
|
result = _parse_gltf_json(json.dumps(gltf_json).encode())
|
|
assert result["asset"]["version"] == "2.0"
|
|
|
|
def test_rejects_non_object(self):
|
|
with pytest.raises(ValueError, match="root must be an object"):
|
|
_parse_gltf_json(b"[1, 2, 3]")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_gltf_metadata (GLB path)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestExtractGltfMetadataGlb:
|
|
def test_basic_glb(self):
|
|
gltf_json = _make_gltf_json(meshes=1, vertices_per_mesh=24)
|
|
glb = _make_glb(gltf_json)
|
|
meta = extract_gltf_metadata(glb)
|
|
assert meta["mesh_count"] == 1
|
|
assert meta["vertex_count"] == 24
|
|
assert meta["has_animation"] is False
|
|
assert meta["bounding_box_min"] == [-1.0, -1.0, -1.0]
|
|
assert meta["bounding_box_max"] == [1.0, 1.0, 1.0]
|
|
assert meta["texture_refs"] == []
|
|
|
|
def test_multiple_meshes(self):
|
|
gltf_json = _make_gltf_json(meshes=3, vertices_per_mesh=12)
|
|
glb = _make_glb(gltf_json)
|
|
meta = extract_gltf_metadata(glb)
|
|
assert meta["mesh_count"] == 3
|
|
assert meta["vertex_count"] == 36
|
|
|
|
def test_with_animation(self):
|
|
gltf_json = _make_gltf_json(has_animations=True)
|
|
glb = _make_glb(gltf_json)
|
|
meta = extract_gltf_metadata(glb)
|
|
assert meta["has_animation"] is True
|
|
|
|
def test_with_external_texture(self):
|
|
gltf_json = _make_gltf_json(images=[{"uri": "textures/rock.png"}])
|
|
glb = _make_glb(gltf_json)
|
|
meta = extract_gltf_metadata(glb)
|
|
assert meta["texture_refs"] == ["textures/rock.png"]
|
|
|
|
def test_with_named_texture(self):
|
|
gltf_json = _make_gltf_json(images=[{"name": "my_texture"}])
|
|
glb = _make_glb(gltf_json)
|
|
meta = extract_gltf_metadata(glb)
|
|
assert meta["texture_refs"] == ["my_texture"]
|
|
|
|
def test_empty_meshes(self):
|
|
gltf_json = _make_gltf_json(meshes=0)
|
|
glb = _make_glb(gltf_json)
|
|
meta = extract_gltf_metadata(glb)
|
|
assert meta["mesh_count"] == 0
|
|
assert meta["vertex_count"] == 0
|
|
assert meta["bounding_box_min"] == [0.0, 0.0, 0.0]
|
|
assert meta["bounding_box_max"] == [0.0, 0.0, 0.0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_gltf_metadata (plain .gltf JSON path)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestExtractGltfMetadataGltfJson:
|
|
def test_plain_gltf(self):
|
|
gltf_json = _make_gltf_json(meshes=2, vertices_per_mesh=8)
|
|
raw = json.dumps(gltf_json).encode()
|
|
meta = extract_gltf_metadata(raw)
|
|
assert meta["mesh_count"] == 2
|
|
assert meta["vertex_count"] == 16
|
|
assert meta["has_animation"] is False
|
|
|
|
def test_plain_gltf_with_animation(self):
|
|
gltf_json = _make_gltf_json(has_animations=True)
|
|
raw = json.dumps(gltf_json).encode()
|
|
meta = extract_gltf_metadata(raw)
|
|
assert meta["has_animation"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestExtractGltfMetadataErrors:
|
|
def test_invalid_glb(self):
|
|
with pytest.raises(ValueError):
|
|
extract_gltf_metadata(b"not a glb")
|
|
|
|
def test_invalid_gltf_json(self):
|
|
with pytest.raises(ValueError):
|
|
_parse_gltf_json(b"[1, 2, 3]")
|