153 lines
10 KiB
Python
153 lines
10 KiB
Python
"""Synthetic inputs only: no game masters or real cleanup execution."""
|
|
import copy,io,json,os,struct,tempfile,unittest,subprocess,sys
|
|
from unittest import mock
|
|
from types import SimpleNamespace
|
|
from pathlib import Path
|
|
import numpy as np
|
|
from PIL import Image
|
|
import hd_grating_gray_cleanup as m
|
|
|
|
class GrayCastTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp=tempfile.TemporaryDirectory();self.root=Path(self.temp.name)
|
|
pal=np.zeros((256,3),np.uint8);pal[11]=[18,18,18];pal[30]=[4,4,4]
|
|
(self.root/'palette.bin').write_bytes(pal.tobytes())
|
|
y,x=np.indices((128,256));rgb=np.stack([50+x%5+y%7,35+x%5+y%7,28+x%5+y%7],axis=2).astype('uint8')
|
|
im=Image.fromarray(rgb);im.save(self.root/'parent.png')
|
|
self.source=np.full((32,32),199,np.uint8);self.source[2:31,2:31]=11
|
|
self.recipe=dict(format_version=1,method=m.METHOD,palette=self.bind('palette.bin'),parent_raw=self.bind('parent.png'),frames=[])
|
|
for asset in ['203c6','203c7']:
|
|
name=asset+'.bin';(self.root/name).write_bytes(struct.pack('<5i',1,1,0,32,32)+self.source.tobytes())
|
|
self.recipe['frames'].append(dict(asset=asset,Source=self.bind(name),selected_cells_sha256=m.selected_digest(m.semantic_mask(asset,self.source))))
|
|
def tearDown(self):self.temp.cleanup()
|
|
def bind(self,n):return dict(path=n,sha256=m.sha((self.root/n).read_bytes()))
|
|
def prepared(self):return m.prepare_inputs(self.recipe,self.root)
|
|
def test_cast_neutral_preserves_generated_luminance(self):
|
|
result=m.derive(*self.prepared());self.assertEqual(len(result),2)
|
|
for a,out,metrics in result:
|
|
self.assertEqual(out.shape,(128,128,4));self.assertTrue(np.all(out[:,:,3]==255))
|
|
self.assertTrue(metrics['outside_resized_parent_exact']);self.assertEqual(metrics['exact_Original_pixel_copies'],0)
|
|
self.assertLessEqual(metrics['max_luma_structure_rounding_error'],.5000001)
|
|
self.assertGreater(metrics['generated_luma_variance'],1);self.assertGreater(metrics['output_luma_variance'],1)
|
|
self.assertLess(abs(metrics['output_luma_variance']-metrics['generated_luma_variance']),2)
|
|
def test_repeatability(self):
|
|
a=m.derive(*self.prepared());b=m.derive(*self.prepared())
|
|
self.assertTrue(all(np.array_equal(x[1],y[1])and x[2]==y[2]for x,y in zip(a,b)))
|
|
def test_all_semantic_cells_not_error_picker(self):
|
|
for asset in m.REGIONS:
|
|
mask=m.semantic_mask(asset,self.source);x0,y0,x1,y1=m.REGIONS[asset]
|
|
self.assertEqual(int(mask.sum()),int(np.count_nonzero(self.source[y0:y1,x0:x1]==11)))
|
|
def test_missing_or_unknown_fields(self):
|
|
for key in ['method','palette','frames']:
|
|
r=copy.deepcopy(self.recipe);del r[key]
|
|
with self.assertRaises(ValueError):m.validate_recipe(r)
|
|
r=copy.deepcopy(self.recipe);r['gain']=1.2
|
|
with self.assertRaises(ValueError):m.validate_recipe(r)
|
|
def test_bool_version_rejected(self):
|
|
r=copy.deepcopy(self.recipe);r['format_version']=True
|
|
with self.assertRaises(ValueError):m.validate_recipe(r)
|
|
def test_order_and_selector_are_fixed(self):
|
|
r=copy.deepcopy(self.recipe);r['frames'].reverse()
|
|
with self.assertRaises(ValueError):m.validate_recipe(r)
|
|
p,raw,frames=self.prepared();frames[0][0]['selected_cells_sha256']='0'*64
|
|
with self.assertRaises(ValueError):m.derive(p,raw,frames)
|
|
def test_wrong_hash_and_escaping_paths(self):
|
|
for ref in [dict(path='parent.png',sha256='0'*64),dict(path='../parent.png',sha256='0'*64),dict(path='/parent.png',sha256='0'*64),dict(path='a/../parent.png',sha256='0'*64)]:
|
|
with self.assertRaises((ValueError,OSError)):m.read_bound(self.root,ref)
|
|
def test_symlink_file_and_parent_rejected(self):
|
|
(self.root/'alias.png').symlink_to(self.root/'parent.png')
|
|
with self.assertRaises(OSError):m.read_bound(self.root,dict(path='alias.png',sha256=self.bind('parent.png')['sha256']))
|
|
(self.root/'folder').symlink_to(self.root,target_is_directory=True)
|
|
with self.assertRaises(OSError):m.read_bound(self.root,dict(path='folder/parent.png',sha256=self.bind('parent.png')['sha256']))
|
|
def test_bytes_are_not_reopened(self):
|
|
prepared=self.prepared();first=m.derive(*prepared)
|
|
(self.root/'parent.png').write_bytes(b'corrupted after authenticated read')
|
|
for a,b in zip(first,m.derive(*prepared)):self.assertTrue(np.array_equal(a[1],b[1]))
|
|
def test_flat_parent_rejected(self):
|
|
p,_,f=self.prepared();stream=io.BytesIO();Image.new('RGB',(256,128),(50,35,28)).save(stream,format='PNG')
|
|
with self.assertRaisesRegex(ValueError,'witness'):m.derive(p,stream.getvalue(),f)
|
|
def test_transparency_size_and_shift_caps(self):
|
|
p,_,f=self.prepared()
|
|
for image in [Image.new('RGBA',(256,128),(50,40,30,0)),Image.new('RGB',(128,128)),Image.new('RGB',(256,64))]:
|
|
stream=io.BytesIO();image.save(stream,format='PNG')
|
|
with self.assertRaises(ValueError):m.derive(p,stream.getvalue(),f)
|
|
p[11]=[240,240,240]
|
|
with self.assertRaisesRegex(ValueError,'fixed32'):m.derive(p,self.prepared()[1],f)
|
|
def test_source_and_palette_validation(self):
|
|
for b in [b'',struct.pack('<5i',2,1,0,32,32)+self.source.tobytes(),struct.pack('<5i',1,1,0,32,32)+bytes([254])*1024]:
|
|
with self.assertRaises(ValueError):m.source_indices(b)
|
|
for b in [bytes(767),bytes([64])*768]:
|
|
with self.assertRaises(ValueError):m.palette_rgb(b)
|
|
def test_fresh_publication_receipt_and_no_overwrite(self):
|
|
(self.root/'recipe.json').write_text(json.dumps(self.recipe));out=self.root/'new-output'
|
|
result=m.run('recipe.json',self.root,out)
|
|
self.assertEqual(set(result['execution']),{'status','method','recipe_sha256','tool_sha256','source_sha256','parent_raw_sha256','output_sha256'})
|
|
self.assertEqual(set(result['execution']['output_sha256']),{'203c6','203c7'})
|
|
before=sorted(p.name for p in out.iterdir())
|
|
with self.assertRaises(ValueError):m.run('recipe.json',self.root,out)
|
|
self.assertEqual(before,sorted(p.name for p in out.iterdir()))
|
|
def test_existing_empty_directory_no_replace(self):
|
|
stage=self.root/'stage';target=self.root/'target';stage.mkdir();target.mkdir()
|
|
with self.assertRaises(OSError):m.publish_no_replace(stage,target)
|
|
self.assertTrue(stage.is_dir());self.assertTrue(target.is_dir())
|
|
|
|
def test_outside_and_alpha_exact_against_independent_baseline(self):
|
|
palette,raw,frames=self.prepared();result=m.derive(palette,raw,frames)
|
|
with Image.open(io.BytesIO(raw))as im:parent=np.asarray(im.convert('RGBA'))
|
|
for col,((asset,out,_),(_,src))in enumerate(zip(result,frames)):
|
|
baseline=np.asarray(Image.fromarray(parent[:,col*128:(col+1)*128]).resize((128,128),Image.Resampling.LANCZOS))
|
|
area=np.repeat(np.repeat(m.semantic_mask(asset,src),4,0),4,1)
|
|
np.testing.assert_array_equal(out[~area],baseline[~area])
|
|
np.testing.assert_array_equal(out[:,:,3],baseline[:,:,3])
|
|
ys,xs=np.nonzero(area);a=(ys[0],xs[0]);different=np.abs((baseline[:,:,:3]@m.LUMA)-(baseline[a][:3]@m.LUMA))>2
|
|
by,bx=np.nonzero(area&different);b=(by[0],bx[0])
|
|
original_difference=float((baseline[a][:3].astype(float)-baseline[b][:3])@m.LUMA)
|
|
output_difference=float((out[a][:3].astype(float)-out[b][:3])@m.LUMA)
|
|
self.assertLessEqual(abs(original_difference-output_difference),1.0000002)
|
|
def test_gray_index_boundaries_and_exclusive_region_edges(self):
|
|
for asset,(x0,y0,x1,y1)in m.REGIONS.items():
|
|
source=np.full((32,32),11,np.uint8);source[y0,x0:x0+4]=[10,11,30,31]
|
|
mask=m.semantic_mask(asset,source)
|
|
self.assertEqual(mask[y0,x0:x0+4].tolist(),[False,True,True,False])
|
|
self.assertTrue(mask[y1-1,x1-1]);self.assertFalse(mask[y1,x1-1]);self.assertFalse(mask[y0,x1])
|
|
self.assertFalse(mask[y0,x0-1])
|
|
if y0:self.assertFalse(mask[y0-1,x0])
|
|
def test_clipping_is_rejected_without_luminance_compression(self):
|
|
palette,raw,frames=self.prepared()
|
|
with Image.open(io.BytesIO(raw))as im:a=np.asarray(im.convert('RGB')).copy()
|
|
a[30,90]=255;stream=io.BytesIO();Image.fromarray(a).save(stream,format='PNG')
|
|
with self.assertRaisesRegex(ValueError,'clipping'):m.derive(palette,stream.getvalue(),frames)
|
|
def test_nonneutral_source_anchor_rejected(self):
|
|
palette,raw,frames=self.prepared();palette[11]=[75,50,30]
|
|
with self.assertRaisesRegex(ValueError,'near-neutral'):m.derive(palette,raw,frames)
|
|
def test_non_rgb_parent_and_malformed_frames_rejected(self):
|
|
p,raw,f=self.prepared()
|
|
for mode in ['L','P']:
|
|
stream=io.BytesIO();Image.new(mode,(256,128)).save(stream,format='PNG')
|
|
with self.assertRaises(ValueError):m.derive(p,stream.getvalue(),f)
|
|
for bad in [None,7,'203c6',[]]:
|
|
r=copy.deepcopy(self.recipe);r['frames'][0]=bad
|
|
with self.assertRaises(ValueError):m.validate_recipe(r)
|
|
for bad in [None,7,[]]:
|
|
with self.assertRaises(ValueError):m.read_regular(self.root,bad)
|
|
def test_fifo_is_nonblocking_and_regular_rejected(self):
|
|
os.mkfifo(self.root/'pipe')
|
|
code="import hd_grating_gray_cleanup as m; m.read_regular(__import__('sys').argv[1], 'pipe')"
|
|
env=dict(os.environ,PYTHONPATH=str(Path(m.__file__).parent))
|
|
child=subprocess.run([sys.executable,'-c',code,str(self.root)],capture_output=True,timeout=3,env=env)
|
|
self.assertNotEqual(child.returncode,0);self.assertIn(b'bounded regular input required',child.stderr)
|
|
def test_oversize_short_and_changed_reads_rejected(self):
|
|
with self.assertRaisesRegex(ValueError,'bounded regular'):m.read_regular(self.root,'parent.png',limit=2)
|
|
with mock.patch.object(m.os,'fdopen',return_value=io.BytesIO(b'x')):
|
|
with self.assertRaisesRegex(ValueError,'changed'):m.read_regular(self.root,'parent.png')
|
|
real=m.os.fstat;calls=[0]
|
|
def changed(fd):
|
|
st=real(fd);calls[0]+=1
|
|
values={k:getattr(st,k)for k in ['st_mode','st_dev','st_ino','st_size','st_mtime_ns','st_ctime_ns']}
|
|
if calls[0]%2==0:values['st_mtime_ns']+=1
|
|
return SimpleNamespace(**values)
|
|
with mock.patch.object(m.os,'fstat',side_effect=changed):
|
|
with self.assertRaisesRegex(ValueError,'changed'):m.read_regular(self.root,'parent.png')
|
|
|
|
if __name__=='__main__':unittest.main()
|