Fix editor regressions and add Blender scene and animated material import
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"""Synthetic integration fixture; does not use any user's project assets."""
|
||||
import bpy
|
||||
import sys
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
scene = bpy.context.scene
|
||||
scene.name = 'ExportFixture'
|
||||
scene.frame_start, scene.frame_end, scene.render.fps = 1, 3, 24
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
obj = bpy.context.object
|
||||
obj.name = 'AnimatedCube'
|
||||
material = bpy.data.materials.new('AnimatedMaterial')
|
||||
material.use_nodes = True
|
||||
obj.data.materials.append(material)
|
||||
tree = material.node_tree
|
||||
principled = tree.nodes.get('Principled BSDF')
|
||||
noise = tree.nodes.new('ShaderNodeTexNoise')
|
||||
noise.noise_dimensions = '4D'
|
||||
noise.inputs['W'].driver_add('default_value').driver.expression = 'frame * 0.7'
|
||||
tree.links.new(noise.outputs['Color'], principled.inputs['Base Color'])
|
||||
for frame, value in [(1, .2), (3, .8)]:
|
||||
principled.inputs['Roughness'].default_value = value
|
||||
principled.inputs['Roughness'].keyframe_insert('default_value', frame=frame)
|
||||
obj.shape_key_add(name='Basis')
|
||||
key = obj.shape_key_add(name='Stretch')
|
||||
key.data[0].co.z += 1
|
||||
for frame, value in [(1, 0), (3, 1)]:
|
||||
key.value = value
|
||||
key.keyframe_insert('value', frame=frame)
|
||||
bpy.ops.object.camera_add(location=(0, 0, 5))
|
||||
scene.camera = bpy.context.object
|
||||
scene.camera.name = 'ActiveCameraObject'
|
||||
scene.camera.data.name = 'ActiveCameraData'
|
||||
bpy.ops.object.light_add(type='POINT', location=(0, -2, 3))
|
||||
bpy.context.object.data.energy = 100
|
||||
curve = bpy.data.curves.new('CurveGeometry', 'CURVE')
|
||||
curve.dimensions = '3D'
|
||||
curve.bevel_depth = .1
|
||||
spline = curve.splines.new('POLY')
|
||||
spline.points.add(1)
|
||||
spline.points[0].co, spline.points[1].co = (0, 0, 0, 1), (1, 1, 1, 1)
|
||||
curve_obj = bpy.data.objects.new('ConvertedCurve', curve)
|
||||
scene.collection.objects.link(curve_obj)
|
||||
# A second scene must not leak into the chosen scene's export.
|
||||
other = bpy.data.scenes.new('ExcludedScene')
|
||||
other.collection.objects.link(bpy.data.objects.new('UnrelatedObject', bpy.data.meshes.new('Unused')))
|
||||
scene.frame_set(1)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=sys.argv[sys.argv.index('--') + 1])
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {promisify} from 'node:util';
|
||||
import {execFile} from 'node:child_process';
|
||||
import {mkdtemp,readFile,rm} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {inspectModel,modelDocument} from '../engine/model.ts';
|
||||
const exec=promisify(execFile), binary=process.env.FORMA_BLENDER;
|
||||
test('Blender exports shape keys, curves, cameras, lights and driver-baked animated textures', {skip:!binary,timeout:180000}, async()=>{
|
||||
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-blender-'));
|
||||
try{
|
||||
const input=path.join(dir,'input.blend'),output=path.join(dir,'output.glb');
|
||||
await exec(binary!,['--background','--factory-startup','--disable-autoexec','--python-exit-code','1','--python',path.resolve('tests/blender-fixture.py'),'--',input],{maxBuffer:4*1024*1024});
|
||||
const original=await readFile(input);
|
||||
await exec(process.execPath,['scripts/export-blender.mjs',input,'--output',output,'--bake-materials','--resolution','16','--samples','1'],{maxBuffer:4*1024*1024});
|
||||
assert.deepEqual(await readFile(input),original,'the source .blend is never saved or modified');
|
||||
const bytes=new Uint8Array(await readFile(output)),doc=modelDocument(bytes,'output.glb'),meta=inspectModel(bytes,'output.glb');
|
||||
assert.equal(doc.scenes.length,1);assert.equal(meta.cameras.length,1);assert.equal(meta.lights,1);assert.equal(meta.morphTargets,1);
|
||||
assert.equal(meta.activeCamera,'ActiveCameraData');
|
||||
assert.ok(Math.abs(doc.extensions.KHR_lights_punctual.lights[0].intensity-100/(4*Math.PI))<.001,'Forma-compatible light units avoid overexposure');
|
||||
assert.ok(doc.nodes.some((n:any)=>n.name==='ConvertedCurve'&&n.mesh!==undefined));
|
||||
assert.ok(!doc.nodes.some((n:any)=>n.name==='UnrelatedObject'));
|
||||
assert.ok(meta.animatedProperties.some(p=>p.endsWith('KHR_texture_transform/offset')));
|
||||
const report=JSON.parse(await readFile(output.replace('.glb','.report.json'),'utf8'));
|
||||
assert.equal(report.bakedMaterials[0].frames,3);
|
||||
const material=doc.materials.find((m:any)=>m.name.startsWith('FormaBake_'));
|
||||
assert.ok(material.pbrMetallicRoughness.baseColorTexture.extensions.KHR_texture_transform);
|
||||
}finally{await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
+36
-1
@@ -39,7 +39,7 @@ export function findNode(p: any, id: string) {
|
||||
}
|
||||
|
||||
/** A generated triangle, optionally skinned and animated; no game assets. */
|
||||
export function triangleGlb(animated = false): Buffer {
|
||||
export function triangleGlb(animated = false, configure?: (document: any, add: (data: Float32Array | Uint16Array, type: string, count: number, bounds?: Record<string, any>) => number) => void): Buffer {
|
||||
const chunks: Buffer[] = [];
|
||||
const views: any[] = [];
|
||||
const accessors: any[] = [];
|
||||
@@ -115,6 +115,7 @@ export function triangleGlb(animated = false): Buffer {
|
||||
},
|
||||
];
|
||||
}
|
||||
configure?.(document, add);
|
||||
Object.assign(document, {
|
||||
buffers: [{ byteLength: length }],
|
||||
bufferViews: views,
|
||||
@@ -153,3 +154,37 @@ export const triangleAsset = (animated = false) => ({
|
||||
uri:
|
||||
"data:model/gltf-binary;base64," + triangleGlb(animated).toString("base64"),
|
||||
});
|
||||
|
||||
/** Material, camera, light and morph animation in one real glTF clip. */
|
||||
export function sceneAnimationGlb() {
|
||||
return triangleGlb(false, (doc, add) => {
|
||||
doc.materials = [{ name: 'AnimatedPBR', pbrMetallicRoughness: { baseColorFactor: [1, 0, 0, 1], metallicFactor: .1, roughnessFactor: .2 } }];
|
||||
doc.meshes[0].primitives[0].material = 0;
|
||||
doc.meshes[0].primitives[0].targets = [{ POSITION: add(new Float32Array([0,0,0, 0,0,0, 0,1,0]), 'VEC3', 3) }];
|
||||
doc.meshes[0].weights = [0];
|
||||
doc.cameras = [{ name: 'ImportedCamera', type: 'perspective', perspective: { yfov: .7, znear: .1 } }];
|
||||
doc.nodes.push({ name: 'ImportedCamera', camera: 0, translation: [0, 0, 5] }, { name: 'ImportedLight', extensions: { KHR_lights_punctual: { light: 0 } } });
|
||||
doc.scenes[0].nodes.push(1, 2);
|
||||
doc.cameras.push({ name: 'Ortho', type: 'orthographic', orthographic: { xmag: 2, ymag: 1, znear: .1, zfar: 100 } });
|
||||
doc.nodes.push({ name: 'Ortho', camera: 1 });doc.scenes[0].nodes.push(3);
|
||||
doc.extensions = { KHR_lights_punctual: { lights: [{ name: 'ImportedLight', type: 'point', intensity: 10 }] } };
|
||||
doc.extensionsUsed = ['KHR_lights_punctual', 'KHR_animation_pointer'];
|
||||
const input = add(new Float32Array([0, 1]), 'SCALAR', 2, { min: [0], max: [1] });
|
||||
const animation: any = { name: 'Scene', channels: [], samplers: [] };
|
||||
for (const [pointer, type, values] of [
|
||||
['/materials/0/pbrMetallicRoughness/baseColorFactor', 'VEC4', [1,0,0,1, 0,0,1,.5]],
|
||||
['/materials/0/pbrMetallicRoughness/metallicFactor', 'SCALAR', [.1,.9]],
|
||||
['/materials/0/pbrMetallicRoughness/roughnessFactor', 'SCALAR', [.2,.8]],
|
||||
['/cameras/0/perspective/yfov', 'SCALAR', [.7,1.1]],
|
||||
['/cameras/1/orthographic/xmag', 'SCALAR', [2,4]],
|
||||
['/cameras/1/orthographic/ymag', 'SCALAR', [1,3]],
|
||||
['/extensions/KHR_lights_punctual/lights/0/intensity', 'SCALAR', [10,20]],
|
||||
] as const) {
|
||||
animation.channels.push({ sampler: animation.samplers.length, target: { path: 'pointer', extensions: { KHR_animation_pointer: { pointer } } } });
|
||||
animation.samplers.push({ input, output: add(new Float32Array(values), type, 2), interpolation: 'LINEAR' });
|
||||
}
|
||||
animation.channels.push({ sampler: animation.samplers.length, target: { node: 0, path: 'weights' } });
|
||||
animation.samplers.push({ input, output: add(new Float32Array([0,1]), 'SCALAR', 2), interpolation: 'LINEAR' });
|
||||
doc.animations = [animation];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {promisify} from 'node:util';
|
||||
import {execFile} from 'node:child_process';
|
||||
import {mkdtemp,writeFile,readFile,access,rm} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {gameArchive,engineResource} from '../engine/archive.ts';
|
||||
import {buildKit} from '../engine/build-kit.ts';
|
||||
import {defaultProject} from '../engine/templates.ts';
|
||||
|
||||
test('hosted editor HTML and downloadable builds resolve inside a nested publication',async()=>{
|
||||
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-hosted-'));
|
||||
const previous=Object.getOwnPropertyDescriptor(globalThis,'document');
|
||||
try{
|
||||
const project=defaultProject(true),source=path.join(dir,'source.forma.json'),out=path.join(dir,'demo');
|
||||
await writeFile(source,JSON.stringify(project));
|
||||
await promisify(execFile)(process.execPath,['--import','tsx','scripts/export-editor.ts',source,out]);
|
||||
const html=await readFile(path.join(out,'index.html'),'utf8');
|
||||
for(const match of html.matchAll(/(?:src|href)="([^"]+)"/g)){
|
||||
assert.ok(match[1].startsWith('./'),match[1]);await access(path.join(out,match[1]));
|
||||
}
|
||||
Object.defineProperty(globalThis,'document',{value:{baseURI:'https://example.test/demo/index.html'},configurable:true});
|
||||
const requested:string[]=[];
|
||||
const read=async(uri:string)=>{requested.push(uri);assert.ok(uri.startsWith('https://example.test/demo/'),uri);return new Uint8Array(await readFile(path.join(out,new URL(uri).pathname.slice('/demo/'.length))));};
|
||||
await gameArchive(project,read);await buildKit(project,{target:'linux',name:'Fixture'},read);
|
||||
assert.ok(requested.includes('https://example.test/demo/engine/player.js'));
|
||||
assert.ok(requested.includes('https://example.test/demo/build-targets/manifest.json'));
|
||||
assert.equal(engineResource('/engine/player.js','https://example.test/demo/'),'https://example.test/demo/engine/player.js');
|
||||
}finally{
|
||||
if(previous)Object.defineProperty(globalThis,'document',previous);else delete (globalThis as any).document;
|
||||
await rm(dir,{recursive:true,force:true});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { zipSync, strToU8 } from 'fflate';
|
||||
import { portableModel, modelComponents, modelResourcePath } from '../engine/model-import.ts';
|
||||
import { inspectModel, modelDocument } from '../engine/model.ts';
|
||||
import { sceneAnimationGlb } from './fixtures.ts';
|
||||
|
||||
test('model metadata exposes scene contents and material animation',()=>{
|
||||
const m=inspectModel(sceneAnimationGlb(),'scene.glb');
|
||||
assert.deepEqual(m.clips,['Scene']);assert.equal(m.lights,1);assert.equal(m.morphTargets,1);assert.equal(m.cameras.length,2);
|
||||
assert.ok(m.animatedProperties.includes('/materials/0/pbrMetallicRoughness/roughnessFactor'));
|
||||
assert.equal(modelComponents('a',m).animator!.autoplay,'Scene');
|
||||
});
|
||||
test('zipped glTF bundles embed relative buffers and textures for offline reload',async()=>{
|
||||
const gltf={asset:{version:'2.0'},buffers:[{uri:'../geometry.bin',byteLength:4}],images:[{uri:'textures/color%20map.png'}]};
|
||||
const zip=zipSync({'scene/model.gltf':strToU8(JSON.stringify(gltf)),'geometry.bin':new Uint8Array([1,2,3,4]),'scene/textures/color map.png':new Uint8Array([9,8,7])});
|
||||
const model=await portableModel(zip,'scene.zip'),doc=modelDocument(model.bytes,model.name);
|
||||
assert.equal(model.name,'model.gltf');assert.match(doc.buffers[0].uri,/^data:application\/octet-stream;base64,AQIDBA==$/);
|
||||
assert.match(doc.images[0].uri,/^data:image\/png;base64,CQgH$/);
|
||||
});
|
||||
test('model import rejects missing resources, remote URLs, traversal and ambiguous bundles',async()=>{
|
||||
for(const uri of ['../../outside.bin','https://example.test/a.bin','%2fetc/passwd','C:/secret','textures\\a.png']) assert.throws(()=>modelResourcePath('scene/a.gltf',uri));
|
||||
await assert.rejects(()=>portableModel(strToU8(JSON.stringify({asset:{version:'2.0'},buffers:[{uri:'a.bin'}]})),'a.gltf'),/связанные/);
|
||||
await assert.rejects(()=>portableModel(zipSync({'a.glb':sceneAnimationGlb(),'b.glb':sceneAnimationGlb()}),'model.zip'),/ровно одну/);
|
||||
});
|
||||
|
||||
import {modelProject} from '../engine/model-import.ts';
|
||||
import {validateProject,activeScene} from '../engine/schema.ts';
|
||||
test('opening an imported scene uses its camera, timeline and lighting',async()=>{
|
||||
const p=modelProject(await portableModel(sceneAnimationGlb(),'scene.glb'));
|
||||
validateProject(p);const n=activeScene(p).entities[0];
|
||||
assert.equal(n.components.camera.mode,'imported');assert.equal(n.components.animator.autoplay,'Scene');
|
||||
assert.equal(p.settings.rendering!.defaultLights,false);assert.equal(p.assets.length,1);
|
||||
});
|
||||
@@ -184,3 +184,19 @@ test("real MCP HTTP + stdio share revisions, models, scripts, resources and expo
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('MCP scene ZIP import replaces the scene atomically and Undo restores the prior project',async()=>{
|
||||
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-scene-'));
|
||||
const s=await createService({projectDir:dir,port:0,blank:true});const client=new Client({name:'scene-test',version:'1'});
|
||||
try{
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${s.port}/mcp`),{requestInit:{headers:{authorization:'Bearer '+s.token}}}));
|
||||
const original=structuredClone(s.service.store.project);
|
||||
const {zipSync}=await import('fflate');const {sceneAnimationGlb}=await import('./fixtures.ts');
|
||||
const archive=zipSync({'scene.glb':sceneAnimationGlb()});
|
||||
const result=await client.callTool({name:'asset_import_glb',arguments:{expectedRevision:0,name:'scene.zip',base64:Buffer.from(archive).toString('base64'),asScene:true}});
|
||||
assert.ok(!result.isError,JSON.stringify(result.content));
|
||||
const p=s.service.store.project,n=p.scenes[0].entities[0];assert.equal(p.revision,1);assert.equal(n.components.camera.mode,'imported');assert.equal(n.components.animator.autoplay,'Scene');
|
||||
const undo=await client.callTool({name:'history_undo',arguments:{expectedRevision:1}});assert.ok(!undo.isError,JSON.stringify(undo.content));
|
||||
assert.equal(s.service.store.project.id,original.id);assert.equal(s.service.store.project.assets.length,0);
|
||||
}finally{await client.close();await s.close();await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
@@ -312,3 +312,47 @@ test(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('changing a parent material or transform does not repaint child entities', async () => {
|
||||
const r = runtime(), p = defaultProject(true);
|
||||
const parent = entity('Parent', {mesh:{type:'box'}, material:{color:'#ff0000',alpha:.5}}, [0,0,0], 'parent');
|
||||
const child = entity('Child', {mesh:{type:'box'}, material:{color:'#00ff00',alpha:1}}, [2,0,0], 'child');
|
||||
child.parentId = parent.id; activeScene(p).entities = [parent,child];
|
||||
try {
|
||||
await r.load(p);
|
||||
parent.transform.position = [3,0,0]; parent.components.material.roughness = .1;
|
||||
await r.load(p);
|
||||
const mat:any = r.nodes.get('child')!.getChildMeshes()[0].material;
|
||||
assert.deepEqual(mat.albedoColor.asArray(), [0,1,0]); assert.equal(mat.alpha,1);
|
||||
assert.equal((r.nodes.get('parent')!.getChildMeshes().find(m=>m.metadata.entityId==='parent')!.material as any).roughness,.1);
|
||||
} finally { r.dispose(); }
|
||||
});
|
||||
|
||||
import { sceneAnimationGlb } from './fixtures.ts';
|
||||
test('imported material, morph, camera and light animation targets are independent per entity', async () => {
|
||||
const r=runtime(), p=defaultProject(true), bytes=sceneAnimationGlb();
|
||||
p.assets=[{id:'scene',name:'scene.glb',kind:'model',uri:'data:model/gltf-binary;base64,'+bytes.toString('base64')}];
|
||||
activeScene(p).entities=['a','b'].map(id=>entity(id,{mesh:{type:'model',assetId:'scene'},...(id==='a'?{camera:{mode:'imported'},animator:{autoplay:'Scene'}}:{})},[0,0,0],id));
|
||||
try {
|
||||
await r.play(p);
|
||||
assert.ok(!r.logs.some(l=>l.level==='error'),JSON.stringify(r.logs));
|
||||
const a=r.containers.get('a')!, b=r.containers.get('b')!;
|
||||
assert.equal(r.scene.activeCamera,a.cameras[0]);
|
||||
const group=r.animations.get('a')![0];
|
||||
assert.ok(group.isStarted,'autoplay starts the imported timeline');
|
||||
group.pause(); group.goToFrame(group.to);
|
||||
const am:any=a.materials[0], bm:any=b.materials[0];
|
||||
const close=(value:number,expected:number)=>assert.ok(Math.abs(value-expected)<.0001,`${value} != ${expected}`);
|
||||
close(am.metallic,.9);close(am.roughness,.8);close(am.albedoColor.b,1);close(am.alpha,.5);
|
||||
close(bm.metallic,.1);close(bm.roughness,.2);close(bm.albedoColor.r,1);
|
||||
close(a.cameras[0].fov,1.1);close(b.cameras[0].fov,.7);
|
||||
close(a.cameras[1].orthoLeft!,-4);close(a.cameras[1].orthoRight!,4);
|
||||
close(a.cameras[1].orthoBottom!,-3);close(a.cameras[1].orthoTop!,3);
|
||||
close(a.lights[0].intensity,20);close(b.lights[0].intensity,10);
|
||||
close(a.morphTargetManagers[0].getTarget(0).influence,1);
|
||||
close(b.morphTargetManagers[0].getTarget(0).influence,0);
|
||||
activeScene(p).entities=activeScene(p).entities.filter(n=>n.id==='b');
|
||||
await r.stop(p);await r.load(p);
|
||||
assert.equal(r.containers.has('a'),false);assert.equal(r.containers.get('b')!.meshes[0].isDisposed(),false);
|
||||
} finally {r.dispose();}
|
||||
});
|
||||
|
||||
@@ -27,3 +27,16 @@ test('local editor still gives the connected Forma project priority over browser
|
||||
const result=await loadEditorProject({}, {request:async(url)=>Response.json(url==='/api/status'?status:{project}),draft:async()=>{throw Error('Must not read draft');}});
|
||||
assert.equal(result.project.name,project.name);assert.deepEqual(result.status,status);
|
||||
});
|
||||
|
||||
import {editorDraftKey} from '../editor/startup.ts';
|
||||
test('hosted drafts are isolated by publication path and bundled project ID',async()=>{
|
||||
const a=defaultProject(true),b=defaultProject(true);a.id='a';b.id='b';
|
||||
const opts={browserOnly:true,initialProject:a,draftScope:'/demo-a/'};
|
||||
const key=editorDraftKey(opts);const saved=structuredClone(a);saved.name='Edited A';
|
||||
const drafts=new Map([[key,saved]]);const draft=async(key:string)=>drafts.get(key)||null;
|
||||
assert.equal((await loadEditorProject(opts,{draft,request:neverRequest})).project.name,'Edited A');
|
||||
assert.equal((await loadEditorProject({...opts,initialProject:b},{draft,request:neverRequest})).project.name,b.name);
|
||||
assert.equal((await loadEditorProject({...opts,draftScope:'/demo-b/'},{draft,request:neverRequest})).restored,false);
|
||||
assert.notEqual(editorDraftKey({browserOnly:true,initialProject:a},'https://host.test/one/'),editorDraftKey({browserOnly:true,initialProject:a},'https://host.test/two/'));
|
||||
assert.equal(editorDraftKey({}),'active');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {bindViewInput} from '../engine/view-input.ts';
|
||||
test('shared player/editor controls request mouse lock and route touch look only while active',()=>{
|
||||
const canvas=new EventTarget() as HTMLCanvasElement;let locks=0;const moves:number[][]=[];
|
||||
canvas.requestPointerLock=async()=>{locks++;};canvas.setPointerCapture=()=>{};
|
||||
const runtime={playing:true,paused:false,firstPerson:()=>true,lookBy:(x:number,y:number)=>moves.push([x,y])};
|
||||
const cleanup=bindViewInput(canvas,runtime);
|
||||
const send=(type:string,props:any)=>canvas.dispatchEvent(Object.assign(new Event(type),props));
|
||||
send('pointerdown',{pointerType:'mouse',button:0});assert.equal(locks,1);
|
||||
send('pointerdown',{pointerType:'touch',pointerId:3,clientX:10,clientY:10});
|
||||
send('pointermove',{pointerId:3,clientX:20,clientY:5});assert.deepEqual(moves,[[.04,.02]]);
|
||||
runtime.paused=true;send('pointerdown',{pointerType:'mouse',button:0});send('pointermove',{pointerId:3,clientX:30,clientY:0});
|
||||
assert.equal(locks,1);assert.equal(moves.length,1);
|
||||
runtime.paused=false;cleanup();send('pointerdown',{pointerType:'mouse',button:0});assert.equal(locks,1);
|
||||
});
|
||||
Reference in New Issue
Block a user