296 lines
16 KiB
Python
296 lines
16 KiB
Python
"""Additional user-input authoring checks shared by the two browser gates.
|
|
Go API calls below only read state/pixels; every edit uses the actual UI.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
|
|
def exercise_authoring(page, checked, browser_name, evidence):
|
|
def state():
|
|
return page.evaluate('() => JSON.parse(aegoArt.request(\'{"action":"state"}\')).state')
|
|
|
|
def image_hash():
|
|
return page.evaluate('() => aegoArt.hash()')
|
|
|
|
def at(x, y):
|
|
b = page.locator('#canvas').bounding_box()
|
|
c = state()['document']['canvas']
|
|
return b['x']+(x+.5)*b['width']/c['width'], b['y']+(y+.5)*b['height']/c['height']
|
|
|
|
def click(x, y):
|
|
page.mouse.click(*at(x, y))
|
|
|
|
def drag(tool, a, b):
|
|
page.locator(f'[data-tool="{tool}"]').click()
|
|
page.mouse.move(*at(*a));page.mouse.down()
|
|
page.mouse.move(*at(*b), steps=1);page.mouse.up()
|
|
|
|
previous = state()['document']['id']
|
|
page.locator('#new').click()
|
|
page.locator('#new-dialog [name=width]').fill('32')
|
|
page.locator('#new-dialog [name=height]').fill('32')
|
|
page.locator('#new-dialog [value=create]').click()
|
|
page.wait_for_function('(old) => JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.document.id !== old',arg=previous)
|
|
blank = image_hash()
|
|
drag('pencil', (2, 2), (26, 2))
|
|
row = page.evaluate('() => [...aegoArt.pixels("",false,false)].slice(2*32*4,3*32*4)')
|
|
assert all(row[x*4+3] == 255 for x in range(2, 27)), 'Fast stroke left holes'
|
|
assert state()['undo'] == 1
|
|
page.locator('#undo').click();assert image_hash() == blank
|
|
page.locator('#redo').click()
|
|
checked('fast-pointer-interpolation-and-one-stroke-history')
|
|
|
|
stroked = image_hash()
|
|
page.touchscreen.tap(*at(24, 28))
|
|
assert image_hash() != stroked and state()['undo'] == 2
|
|
page.locator('#undo').click();assert image_hash() == stroked
|
|
checked('browser-touch-input-uses-the-same-stroke-and-undo')
|
|
if browser_name == 'chromium':
|
|
# Trusted browser input, rather than a dispatched DOM PointerEvent
|
|
# whose nonexistent pointer capture would not exercise the UI path.
|
|
cdp = page.context.new_cdp_session(page)
|
|
x, y = at(26, 28)
|
|
for event, buttons in (('mousePressed', 1), ('mouseReleased', 0)):
|
|
cdp.send('Input.dispatchMouseEvent', {'type':event,'x':x,'y':y,'button':'left',
|
|
'buttons':buttons,'clickCount':1,'pointerType':'pen','force':.7 if buttons else 0})
|
|
assert image_hash() != stroked and state()['undo'] == 2
|
|
page.locator('#undo').click();assert image_hash() == stroked
|
|
cdp.detach()
|
|
checked('chromium-pen-input-uses-the-same-stroke-and-undo')
|
|
|
|
unchanged = image_hash();revision = state()['document']['revision'];count = state()['undo']
|
|
page.locator('[data-tool=rectangle]').click()
|
|
page.locator('#filled').check()
|
|
page.mouse.move(*at(3, 4));page.mouse.down();page.mouse.move(*at(8, 9))
|
|
assert page.evaluate('() => document.activeElement === document.getElementById("canvas")'), 'Canvas did not take keyboard focus from the Filled checkbox'
|
|
assert state()['gesture'] and state()['document']['revision'] == revision
|
|
preview = page.evaluate('() => [...aegoArt.pixels("",false,false)]')
|
|
assert preview[(5*32+5)*4+3] == 255
|
|
page.keyboard.press('Escape');page.mouse.up()
|
|
assert image_hash() == unchanged and state()['undo'] == count and not state()['gesture']
|
|
checked('shape-preview-and-escape-do-not-publish')
|
|
|
|
page.locator('[data-tool=curve]').click()
|
|
count = state()['undo'];revision = state()['document']['revision']
|
|
for p in ((3, 7), (8, 20), (20, 8)):
|
|
click(*p)
|
|
page.mouse.move(*at(27, 26))
|
|
assert state()['gesture'] and state()['document']['revision'] == revision
|
|
click(27, 26)
|
|
assert state()['undo'] == count+1 and not state()['gesture']
|
|
curve_hash = image_hash()
|
|
page.locator('[data-tool=polygon]').click()
|
|
for p in ((3, 14), (9, 14), (7, 22)):
|
|
click(*p)
|
|
assert state()['gesture']
|
|
page.keyboard.press('Enter')
|
|
assert state()['undo'] == count+2 and image_hash() != curve_hash
|
|
page.locator('#undo').click();assert image_hash() == curve_hash
|
|
page.locator('#redo').click()
|
|
checked('curve-and-polygon-live-preview-single-undo')
|
|
|
|
drag('rectangle', (2, 4), (4, 6))
|
|
drag('select-rect', (2, 4), (4, 6))
|
|
count = state()['undo']
|
|
page.locator('#capture-brush').click()
|
|
assert state()['brushWidth'] == 3 and state()['brushHeight'] == 3 and state()['undo'] == count
|
|
click(14, 14)
|
|
pixels = page.evaluate('() => [...aegoArt.pixels("",false,false)]')
|
|
assert all(pixels[(y*32+x)*4+3] == 255 for y in range(13, 16) for x in range(13, 16))
|
|
page.locator('#brush-source').select_option('pattern')
|
|
drag('pencil', (21, 14), (25, 14))
|
|
assert state()['undo'] == count+2
|
|
page.locator('#brush-source').select_option('solid')
|
|
checked('selection-captured-custom-and-pattern-brushes')
|
|
|
|
before_layer = image_hash()
|
|
page.locator('#add-layer').click()
|
|
page.locator('#layer-dialog [name=name]').fill('Merge and isolate')
|
|
page.locator('#layer-dialog [value=apply]').click()
|
|
page.wait_for_function('() => JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.document.layers.length === 2')
|
|
page.locator('#swatches button').nth(12).click()
|
|
drag('rectangle', (1, 1), (5, 5))
|
|
composite = image_hash();count = state()['undo'];revision = state()['document']['revision']
|
|
page.locator('#isolate-layer').click()
|
|
assert state()['isolation'] and state()['document']['revision'] == revision and state()['undo'] == count
|
|
page.locator('#isolate-layer').click()
|
|
topology = state()['document']['cels']
|
|
page.locator('#merge-down').click()
|
|
assert len(state()['document']['layers']) == 1 and image_hash() == composite
|
|
page.locator('#undo').click()
|
|
assert len(state()['document']['layers']) == 2 and state()['document']['cels'] == topology
|
|
page.locator('#redo').click()
|
|
assert image_hash() == composite and composite != before_layer
|
|
checked('layer-isolation-merge-pixels-and-topology-undo')
|
|
|
|
page.locator('#transform').click()
|
|
page.locator('#transform-dialog [name=kind]').select_option('scale')
|
|
page.locator('#transform-dialog [name=width]').fill('20')
|
|
page.locator('#transform-dialog [name=height]').fill('20')
|
|
revision = state()['document']['revision']
|
|
page.locator('#transform-dialog [value=apply]').click()
|
|
page.wait_for_function('(old) => JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.document.revision > old',arg=revision)
|
|
assert image_hash() != composite
|
|
page.locator('#undo').click();assert image_hash() == composite
|
|
page.locator('#pivot').click()
|
|
page.locator('#pivot-dialog [name=x]').fill('8')
|
|
page.locator('#pivot-dialog [name=y]').fill('30')
|
|
page.locator('#pivot-dialog [value=apply]').click()
|
|
page.wait_for_function('() => JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.document.slices.length === 1')
|
|
assert state()['document']['slices'][-1]['pivot'] == {'X':8,'Y':30}
|
|
assert page.locator('#pivot-overlay').is_visible()
|
|
checked('nearest-pixel-scale-and-current-frame-pivot')
|
|
|
|
page.locator('#mode').select_option('indexed')
|
|
indexed = image_hash()
|
|
order = list(range(len(state()['document']['palette'])))
|
|
order[6], order[12] = order[12], order[6]
|
|
page.locator('#palette-order').click()
|
|
page.locator('#palette-order-dialog [name=order]').fill(','.join(map(str,order)))
|
|
revision = state()['document']['revision']
|
|
page.locator('#palette-order-dialog [value=apply]').click()
|
|
page.wait_for_function('(old) => JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.document.revision > old',arg=revision)
|
|
assert image_hash() == indexed, 'Palette reorder changed indexed appearance'
|
|
page.locator('#ramp-shade').click()
|
|
page.locator('#ramp-dialog [name=first]').fill('1')
|
|
page.locator('#ramp-dialog [name=last]').fill('15')
|
|
page.locator('#ramp-dialog [name=step]').fill('1')
|
|
revision = state()['document']['revision']
|
|
page.locator('#ramp-dialog [value=apply]').click()
|
|
page.wait_for_function('(old) => JSON.parse(aegoArt.request(\'{"action":"state"}\')).state.document.revision > old',arg=revision)
|
|
assert image_hash() != indexed
|
|
page.locator('#undo').click();assert image_hash() == indexed
|
|
checked('true-indexed-palette-reorder-and-ramp-shading')
|
|
|
|
exports = Path(evidence) / 'authoring-exports'
|
|
exports.mkdir(exist_ok=False)
|
|
|
|
def decode_png(path):
|
|
# Use the browser's real PNG decoder to inspect downloaded bytes. This
|
|
# canvas is test-only readback; it never implements authoring or export.
|
|
return page.evaluate('''async bytes => {
|
|
const bitmap=await createImageBitmap(new Blob([new Uint8Array(bytes)],{type:'image/png'}));
|
|
const canvas=document.createElement('canvas');canvas.width=bitmap.width;canvas.height=bitmap.height;
|
|
const context=canvas.getContext('2d',{willReadFrequently:true});context.drawImage(bitmap,0,0);
|
|
const result={width:bitmap.width,height:bitmap.height,
|
|
pixels:Array.from(context.getImageData(0,0,bitmap.width,bitmap.height).data)};
|
|
bitmap.close();return result;
|
|
}''',list(path.read_bytes()))
|
|
|
|
# Height is an authored data channel. Do not claim a parallax shader where
|
|
# the renderer only supports displaying/exporting those height samples.
|
|
page.locator('#mode').select_option('rgba')
|
|
page.locator('#channel').select_option('height')
|
|
page.locator('#brush-source').select_option('solid')
|
|
page.locator('#brush-size').fill('1')
|
|
page.locator('#brush-size').press('Tab')
|
|
page.locator('#hex-color').fill('#808080')
|
|
page.locator('#hex-color').press('Tab')
|
|
page.locator('#fg-alpha').fill('255')
|
|
page.locator('#fg-alpha').press('Tab')
|
|
page.locator('#canvas').focus()
|
|
page.keyboard.press('Control+d')
|
|
before_height = image_hash()
|
|
albedo = page.evaluate('() => aegoArt.hash("","albedo")')
|
|
count = state()['undo']
|
|
page.locator('[data-tool=pencil]').click()
|
|
click(11, 12)
|
|
height_hash = image_hash()
|
|
assert height_hash != before_height and state()['undo'] == count+1
|
|
assert page.evaluate('() => aegoArt.hash("","albedo")') == albedo
|
|
assert page.evaluate('() => Array.from(aegoArt.pixels("",false,false)).slice((12*32+11)*4,(12*32+12)*4)') == [128,128,128,255]
|
|
with page.expect_download() as height_download:
|
|
page.locator('#export-png').click()
|
|
height_path = exports / 'height-channel.png'
|
|
height_download.value.save_as(height_path)
|
|
assert height_download.value.failure() is None
|
|
height_image = decode_png(height_path)
|
|
assert (height_image['width'],height_image['height']) == (32,32)
|
|
assert height_image['pixels'][(12*32+11)*4:(12*32+12)*4] == [128,128,128,255]
|
|
assert hashlib.sha256(bytes(height_image['pixels'])).hexdigest() == height_hash
|
|
page.locator('#undo').click();assert image_hash() == before_height
|
|
page.locator('#redo').click();assert image_hash() == height_hash
|
|
checked('height-channel-pointer-undo-redo-and-real-png-download')
|
|
|
|
page.locator('#channel').select_option('albedo')
|
|
for _ in range(6):
|
|
page.locator('#duplicate-frame').click()
|
|
page.locator('#link-frame').click()
|
|
assert len(state()['document']['frames']) == 8
|
|
page.locator('#duration').fill('175')
|
|
page.locator('#duration').press('Tab')
|
|
before_tag = state()['document']
|
|
expected_tag = {'name':'Exported loop','from':before_tag['frames'][0]['id'],
|
|
'to':before_tag['frames'][-1]['id'],'direction':'ping-pong'}
|
|
page.locator('#add-tag').click()
|
|
page.locator('#tag-dialog [name=name]').fill('Exported loop')
|
|
page.locator('#tag-dialog [name=from]').fill('1')
|
|
page.locator('#tag-dialog [name=to]').fill('8')
|
|
page.locator('#tag-dialog [name=direction]').select_option('ping-pong')
|
|
page.locator('#tag-dialog [value=create]').click()
|
|
# The native dialog queues its close event separately from the click.
|
|
# Wait for that handler's actual Go commit, not just the closed dialog.
|
|
page.wait_for_function('''expected => {
|
|
const doc = JSON.parse(aegoArt.request('{"action":"state"}')).state.document;
|
|
const tag = doc.tags[doc.tags.length-1];
|
|
return !document.querySelector('#tag-dialog').open && doc.id === expected.documentID
|
|
&& doc.tags.length === expected.count && Boolean(tag)
|
|
&& tag.name === expected.tag.name && tag.from === expected.tag.from
|
|
&& tag.to === expected.tag.to && tag.direction === expected.tag.direction;
|
|
}''', arg={'documentID':before_tag['id'],'count':len(before_tag['tags'])+1,
|
|
'tag':expected_tag}, timeout=10000)
|
|
assert state()['document']['tags'][-1]['direction'] == 'ping-pong'
|
|
created_tags = state()['document']['tags']
|
|
assert created_tags[:-1] == before_tag['tags'], 'Creating a tag modified existing tags'
|
|
assert created_tags[-1]['id'] and created_tags[-1]['id'] not in {tag['id'] for tag in before_tag['tags']}
|
|
assert {key:created_tags[-1][key] for key in expected_tag} == expected_tag
|
|
# One independent frame differs, so a duplicated-first-frame export cannot
|
|
# satisfy the pixel assertion for all eight sprite sheet regions.
|
|
page.locator('#timeline-table thead button').nth(3).click()
|
|
page.locator('#hex-color').fill('#16a5e8')
|
|
page.locator('#hex-color').press('Tab')
|
|
click(23, 25)
|
|
source = state()
|
|
expected_hashes = page.evaluate('''() => JSON.parse(aegoArt.request('{"action":"state"}')).state.document.frames
|
|
.map(frame=>[frame.id,aegoArt.hash(frame.id,'albedo')])''')
|
|
assert len(set(value for _,value in expected_hashes)) > 1
|
|
with page.expect_download(predicate=lambda item: item.suggested_filename.endswith('-sheet.png')) as png_download, \
|
|
page.expect_download(predicate=lambda item: item.suggested_filename.endswith('-sheet.json')) as metadata_download:
|
|
page.locator('#export-sheet').click()
|
|
sheet_path, metadata_path = exports / 'animation-sheet.png', exports / 'animation-sheet.json'
|
|
png_download.value.save_as(sheet_path)
|
|
metadata_download.value.save_as(metadata_path)
|
|
assert png_download.value.failure() is None and metadata_download.value.failure() is None
|
|
metadata = json.loads(metadata_path.read_text())
|
|
sheet = decode_png(sheet_path)
|
|
assert (sheet['width'],sheet['height']) == (256,32)
|
|
assert (metadata['width'],metadata['height']) == (sheet['width'],sheet['height'])
|
|
assert len(metadata['frames']) == 8
|
|
assert metadata['tags'] == source['document']['tags']
|
|
assert metadata['slices'] == source['document']['slices'] and metadata['slices']
|
|
raw_pixels = bytes(sheet['pixels'])
|
|
actual_hashes = []
|
|
for index,(frame,expected) in enumerate(zip(metadata['frames'],expected_hashes)):
|
|
assert frame['id'] == expected[0] == source['document']['frames'][index]['id']
|
|
assert frame['durationMS'] == source['document']['frames'][index]['durationMS']
|
|
assert frame['rect'] == {'Min':{'X':index*32,'Y':0},'Max':{'X':(index+1)*32,'Y':32}}
|
|
pixels = b''.join(raw_pixels[(y*256+index*32)*4:(y*256+(index+1)*32)*4] for y in range(32))
|
|
digest = hashlib.sha256(pixels).hexdigest()
|
|
assert digest == expected[1], (index,expected[0],'sheet region differs from actual source pixels')
|
|
actual_hashes.append([frame['id'],digest])
|
|
assert state()['document'] == source['document'] and state()['dirty'] == source['dirty']
|
|
assert state()['undo'] == source['undo'], 'Export modified history'
|
|
(exports / 'verification.json').write_text(json.dumps({
|
|
'status':'PASS','browser':browser_name,'sourceDocument':source['document'],
|
|
'heightPixel':[128,128,128,255],'heightPixelHash':height_hash,
|
|
'frameHashes':actual_hashes,'width':sheet['width'],'height':sheet['height'],
|
|
'downloads':[{'path':p.name,'bytes':p.stat().st_size,'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}
|
|
for p in (height_path,sheet_path,metadata_path)],
|
|
'scope':'Actual UI and browser downloads; .aeanim and runtime AssetIDs belong to the native importer.'
|
|
},ensure_ascii=False,indent=2)+'\n')
|
|
checked('actual-spritesheet-download-eight-distinct-regions-durations-tags-and-pivots')
|
|
|
|
page.locator('.document-tab.active .close-document').click()
|
|
assert state()['document']['id'] == previous, 'Closing scratch tab lost the earlier dirty document'
|