"""Offline tests for the downloadable GLB tools. Run beside the three GLBs.
python glb_lab_test.py
Only Python3.9+ standard library; deliberately damaged files stay in temporary folders.
"""
from contextlib import redirect_stdout
import copy
import hashlib
import io
import json
import math
from pathlib import Path
import struct
import tempfile
import unittest
import inspect_glb as lab
import compare_table_silhouettes as diagram
HERE=Path(__file__).resolve().parent

def packed(model,binary):
    js=json.dumps(model,separators=(',',':'),allow_nan=False).encode()
    js+=b' '*((-len(js))%4);data=bytes(binary)+b'\0'*((-len(binary))%4)
    total=12+8+len(js)+8+len(data)
    return struct.pack('<4sII',b'glTF',2,total)+struct.pack('<I4s',len(js),b'JSON')+js+struct.pack('<I4s',len(data),b'BIN\0')+data

class GlbTests(unittest.TestCase):
    def setUp(self):self.model,self.binary,_=lab.read_model(HERE/'table-8.glb')
    def altered(self,change,check):
        model=copy.deepcopy(self.model);binary=bytearray(self.binary);change(model,binary)
        with tempfile.TemporaryDirectory() as folder:
            path=Path(folder)/'changed.glb';path.write_bytes(packed(model,binary));return check(path)
    def test_original_reading_corner(self):
        report=lab.inspect(HERE/'reading-corner.glb')
        self.assertTrue(lab.expect_reading_corner(report));self.assertEqual(report['materials'],6)
    def test_measured_table_counts(self):
        self.assertEqual(lab.inspect(HERE/'table-8.glb')['trianglesInMeshDefinitions'],28)
        self.assertEqual(lab.inspect(HERE/'table-32.glb')['trianglesInMeshDefinitions'],124)
    def test_actual_rim_matches_regular_polygon_geometry(self):
        for sides in (8,32):
            report=diagram.profile(HERE/f'table-{sides}.glb')
            self.assertAlmostEqual(report['maxRadialShortfallMm'],300*(1-math.cos(math.pi/sides)),places=4)
    def test_distributed_svg_matches_actual_model_vertices(self):
        profiles=[diagram.profile(HERE/f'table-{n}.glb') for n in (8,32)]
        self.assertEqual(diagram.draw(profiles),(HERE/'table-comparison.svg').read_text())
    def test_invalid_magic(self):
        with tempfile.TemporaryDirectory() as folder:
            p=Path(folder)/'bad.glb';p.write_bytes(b'NOPE'+(HERE/'table-8.glb').read_bytes()[4:])
            with self.assertRaisesRegex(ValueError,'magic'):lab.read_model(p)
    def test_truncated_glb(self):
        with tempfile.TemporaryDirectory() as folder:
            p=Path(folder)/'bad.glb';p.write_bytes((HERE/'table-8.glb').read_bytes()[:-4])
            with self.assertRaisesRegex(ValueError,'length'):lab.read_model(p)
    def test_false_accessor_bounds(self):
        def change(m,b):m['accessors'][0]['max'][0]=99
        with self.assertRaisesRegex(ValueError,'min/max'):self.altered(change,lab.inspect)
    def test_accessor_outside_bin(self):
        def change(m,b):m['accessors'][0]['count']=1_000_000
        with self.assertRaisesRegex(ValueError,'outside'):self.altered(change,lab.inspect)
    def test_sparse_accessor_refused_instead_of_guessing(self):
        def change(m,b):m['accessors'][0]['sparse']={}
        with self.assertRaisesRegex(ValueError,'Sparse'):self.altered(change,lab.inspect)
    def test_required_compression_refused(self):
        def change(m,b):m['extensionsRequired']=['KHR_draco_mesh_compression']
        with self.assertRaisesRegex(ValueError,'extensions'):self.altered(change,lab.inspect)
    def index_offset(self,m):
        primitive=m['meshes'][0]['primitives'][0];acc=m['accessors'][primitive['indices']]
        view=m['bufferViews'][acc['bufferView']]
        return view.get('byteOffset',0)+acc.get('byteOffset',0),{5121:'B',5123:'H',5125:'I'}[acc['componentType']]
    def test_out_of_range_triangle_index(self):
        def change(m,b):
            offset,fmt=self.index_offset(m);struct.pack_into('<'+fmt,b,offset,255)
        with self.assertRaisesRegex(ValueError,'index'):self.altered(change,lab.inspect)
    def test_degenerate_triangle_is_reported(self):
        def change(m,b):
            offset,fmt=self.index_offset(m);size=struct.calcsize(fmt);first=struct.unpack_from('<'+fmt,b,offset)[0]
            struct.pack_into('<'+fmt,b,offset+size,first)
        report=self.altered(change,lab.inspect);self.assertGreater(report['degenerateTriangles'],0)
    def test_nonfinite_vertex_is_rejected(self):
        def change(m,b):
            view=m['bufferViews'][m['accessors'][0]['bufferView']];struct.pack_into('<f',b,view.get('byteOffset',0),float('nan'))
        with self.assertRaisesRegex(ValueError,'Non-finite'):self.altered(change,lab.inspect)
    def test_wrong_floor_scale_is_not_hidden_by_local_bounds(self):
        model,binary,_=lab.read_model(HERE/'reading-corner.glb')
        floor=next(n for n in model['nodes'] if n.get('name')=='Floor_3m_x_2m');floor['scale']=[100,100,100]
        with tempfile.TemporaryDirectory() as folder:
            p=Path(folder)/'wrong-scale.glb';p.write_bytes(packed(model,binary));report=lab.inspect(p)
            with self.assertRaisesRegex(ValueError,'unscaled'):lab.expect_reading_corner(report)
    def test_reading_does_not_change_inputs(self):
        for name in ('reading-corner.glb','table-8.glb','table-32.glb'):
            p=HERE/name;before=hashlib.sha256(p.read_bytes()).digest();lab.inspect(p)
            self.assertEqual(before,hashlib.sha256(p.read_bytes()).digest())

if __name__=='__main__':unittest.main(verbosity=2)
