diff --git a/e2e/tests/helpers.js b/e2e/tests/helpers.js index 8c0aa612d..b9010288f 100644 --- a/e2e/tests/helpers.js +++ b/e2e/tests/helpers.js @@ -25,6 +25,73 @@ const initLabelStudio = async ({ config, data, completions = [{ result: [] }], p done(); }; +/** + * Wait for the main Image object to be loaded + * @param {function} done codecept async success handler + */ +const waitForImage = async done => { + const img = document.querySelector("[alt=LS]"); + if (!img || img.complete) return done(); + img.onload = done; +}; + +/** + * Float numbers can't be compared strictly, so convert any numbers or structures with numbers + * to same structures but with rounded numbers (int for ints, fixed(2) for floats) + * @param {*} data + */ +const convertToFixed = data => { + if (["string", "number"].includes(typeof data)) { + const n = Number(data); + return Number.isInteger(n) ? n : +Number(n).toFixed(2); + } + if (Array.isArray(data)) { + return data.map(n => convertToFixed(n)); + } + if (typeof data === "object") { + const result = {}; + for (let key in data) { + result[key] = convertToFixed(data[key]); + } + return result; + } + return data; +}; + +/** + * Create convertor for any measures to relative form on image with given dimensions + * Accepts numbers, arrays ([x, y] treated as a special coords array) and hash objects + * With [706, 882] given as image sizes: + * assert.equal(convertToImageSize(123), 17.42); + * assert.deepEqual(convertToImageSize([123, 123]), [17.42, 13.95]); + * assert.deepEqual( + * convertToImageSize({ width: 123, height: 123, radiusY: 123, coords: [123, 123] }), + * { width: 17.42, height: 13.95, radiusY: 13.95, coords: [17.42, 13.95] } + * ); + * @param {number} width + * @param {number} height + */ +const getSizeConvertor = (width, height) => + function convert(data, size = width) { + if (typeof data === "number") return convertToFixed((data * 100) / size); + if (Array.isArray(data)) { + if (data.length === 2) return [convert(data[0]), convert(data[1], height)]; + return data.map(n => convert(n)); + } + if (typeof data === "object") { + const result = {}; + for (let key in data) { + if (key === "rotation") result[key] = data[key]; + else if (key.startsWith("height") || key === "y" || key.endsWith("Y")) result[key] = convert(data[key], height); + else result[key] = convert(data[key]); + } + return result; + } + return data; + }; + +const delay = n => new Promise(resolve => setTimeout(resolve, n)); + // good idea, but it doesn't work :( const emulateClick = source => { const event = document.createEvent("CustomEvent"); @@ -40,6 +107,93 @@ const clickRect = () => { rect.fire("click", { clientX: 10, clientY: 10 }); }; +/** + * Click once on the main Stage + * @param {number} x + * @param {number} y + * @param {function} done + */ +const clickKonva = (x, y, done) => { + const stage = window.Konva.stages[0]; + stage.fire("click", { clientX: x, clientY: y, evt: { offsetX: x, offsetY: y } }); + done(); +}; + +/** + * Click multiple times on the Stage + * @param {number[][]} points array of coords arrays ([[x1, y1], [x2, y2], ...]) + * @param {function} done + */ +const clickMultipleKonva = async (points, done) => { + const stage = window.Konva.stages[0]; + for (let point of points) { + stage.fire("click", { evt: { offsetX: point[0], offsetY: point[1] } }); + // await delay(10); + } + done(); +}; + +/** + * Create Polygon on Stage by clicking multiple times and click on the first point at the end + * @param {number[][]} points array of coords arrays ([[x1, y1], [x2, y2], ...]) + * @param {function} done + */ +const polygonKonva = async (points, done) => { + const delay = () => new Promise(resolve => setTimeout(resolve, 10)); + const stage = window.Konva.stages[0]; + const firstCoords = points[0]; + for (let point of points) { + stage.fire("click", { evt: { offsetX: point[0], offsetY: point[1] } }); + await delay(); + } + + // for closing the Polygon we should place cursor over the first point + const firstPoint = stage.getIntersection({ x: firstCoords[0], y: firstCoords[1] }); + firstPoint.fire("mouseover"); + await delay(); + // and only after that we can click on it + firstPoint.fire("click"); + done(); +}; + +/** + * Click and hold, move the cursor (with one pause in the middle) and release the mouse + * @param {number} x + * @param {number} y + * @param {number} shiftX + * @param {number} shiftY + * @param {function} done + */ +const dragKonva = async (x, y, shiftX, shiftY, done) => { + const stage = window.Konva.stages[0]; + stage.fire("mousedown", { evt: { offsetX: x, offsetY: y } }); + // await delay(10); + stage.fire("mousemove", { evt: { offsetX: x + (shiftX >> 1), offsetY: y + (shiftY >> 1) } }); + // await delay(10); + // we should move the cursor to the last point and only after that release the mouse + stage.fire("mousemove", { evt: { offsetX: x + shiftX, offsetY: y + shiftY } }); + // await delay(10); + // because some events work on mousemove and not on mouseup + stage.fire("mouseup", { evt: { offsetX: x + shiftX, offsetY: y + shiftY } }); + done(); +}; + const serialize = () => window.Htx.completionStore.selected.serializeCompletion(); -module.exports = { initLabelStudio, emulateClick, clickRect, serialize }; +module.exports = { + initLabelStudio, + waitForImage, + delay, + + getSizeConvertor, + convertToFixed, + + emulateClick, + clickRect, + clickKonva, + clickMultipleKonva, + polygonKonva, + dragKonva, + + serialize, +}; diff --git a/e2e/tests/image.shapes.test.js b/e2e/tests/image.shapes.test.js new file mode 100644 index 000000000..2ebc3aa4c --- /dev/null +++ b/e2e/tests/image.shapes.test.js @@ -0,0 +1,155 @@ +/* global Feature, Scenario */ + +const { + initLabelStudio, + waitForImage, + getSizeConvertor, + convertToFixed, + clickKonva, + polygonKonva, + dragKonva, + serialize, +} = require("./helpers"); + +const assert = require("assert"); + +Feature("Test Image object"); + +const getConfigWithShape = (shape, props = "") => ` + + + <${shape} ${props} name="tag" toName="img" /> + `; + +const IMAGE = + "https://htx-misc.s3.amazonaws.com/opensource/label-studio/examples/images/nick-owuor-astro-nic-visuals-wDifg5xc9Z4-unsplash.jpg"; + +// precalculated image size on the screen; may change because of different reasons +const WIDTH = 706; +const HEIGHT = 882; +const convertToImageSize = getSizeConvertor(WIDTH, HEIGHT); + +const completionEmpty = { + id: "1000", + result: [], +}; + +const shapes = [ + { + shape: "KeyPoint", + props: 'strokeWidth="5"', + action: clickKonva, + regions: [ + { + params: [200, 100], + result: { x: 200, y: 100, width: 5 }, + }, + { + params: [100, 100], + result: { x: 100, y: 100, width: 5 }, + }, + ], + }, + { + shape: "Polygon", + action: polygonKonva, + regions: [ + { + // outer array — params, inner array — points as the first param + params: [ + [ + [200, 20], + [400, 100], + [300, 200], + ], + ], + result: { + points: [ + [200, 20], + [400, 100], + [300, 200], + ], + }, + }, + { + // outer array — params, inner array — points as the first param + params: [ + [ + [400, 10], + [400, 90], + [370, 30], + [300, 10], + ], + ], + result: { + points: [ + [400, 10], + [400, 90], + [370, 30], + [300, 10], + ], + }, + }, + ], + }, + { + shape: "Rectangle", + action: dragKonva, + regions: [ + { + params: [100, 210, 80, 30], + result: { width: 80, height: 30, rotation: 0, x: 100, y: 210 }, + }, + { + params: [100, 350, -50, -50], + result: { width: 50, height: 50, rotation: 0, x: 50, y: 300 }, + }, + ], + }, + { + shape: "Ellipse", + action: dragKonva, + regions: [ + { + params: [300, 300, 50, 50], + result: { radiusX: 50, radiusY: 50, rotation: 0, x: 300, y: 300 }, + }, + { + // @todo Ellipse behave differently depending on direction of drawing + // it keeps center at the start point on right-down movement + // and it moves center after the cursor on left-up movement + // @todo looks like a bug + params: [230, 300, -50, -30], + result: { radiusX: 50, radiusY: 30, rotation: 0, x: 180, y: 270 }, + }, + ], + }, +]; + +Scenario("Simple shapes on Image", async function(I) { + for (let shape of shapes) { + const params = { + config: getConfigWithShape(shape.shape, shape.props), + data: { image: IMAGE }, + completions: [completionEmpty], + }; + + I.amOnPage("/"); + await I.executeAsyncScript(initLabelStudio, params); + // canvas won't be initialized fully before the image loads + await I.executeAsyncScript(waitForImage); + I.waitForVisible("canvas"); + I.see("Regions (0)"); + + for (let region of shape.regions) { + // draw the shape using corresponding helper and params + await I.executeAsyncScript(shape.action, ...region.params); + } + + const result = await I.executeScript(serialize); + for (let i = 0; i < shape.regions.length; i++) { + assert.equal(result[i].type, shape.shape.toLowerCase()); + assert.deepEqual(convertToFixed(result[i].value), convertToImageSize(shape.regions[i].result)); + } + } +});