Skip to content

Commit

Permalink
feat: Add aiElement Filter (#CO-731)
Browse files Browse the repository at this point in the history
feat: Add AIElement Filter (#CO-731)
  • Loading branch information
mlikasam-askui authored Jun 21, 2024
2 parents 7787b46 + 272ad68 commit ae59ba0
Show file tree
Hide file tree
Showing 9 changed files with 524 additions and 60 deletions.
92 changes: 92 additions & 0 deletions packages/askui-nodejs/src/core/ai-element/ai-element-collection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import os from 'os';
import path from 'path';
import fs from 'fs-extra';
import { AIElement, AIElementJson } from './ai-element';
import { CustomElementJson } from '../model/custom-element-json';
import { logger } from '../../lib';
import { AIElementError } from './ai-element-error';

export class AIElementCollection {
static AI_ELEMENT_FOLDER = path.join(
os.homedir(),
'.askui',
'SnippingTool',
'AIElement',
);

constructor(private elements: AIElement[]) {}

static async collectForWorkspaceId(
workspaceId: string | undefined,
): Promise<AIElementCollection> {
logger.debug(`Collecting AIElements for workspace '${workspaceId}' ...`);

if (workspaceId === undefined) {
throw new AIElementError("Value of 'workspaceId' must be defined.");
}

const workspaceAIElementFolder = path.join(
AIElementCollection.AI_ELEMENT_FOLDER,
workspaceId,
);

if (!(await fs.pathExists(workspaceAIElementFolder))) {
throw new AIElementError(
`Missing AIElement folder for workspace '${workspaceId}' at '${workspaceAIElementFolder}'.`,
);
}

const files = await fs.readdir(workspaceAIElementFolder);

if (files.length === 0) {
throw new AIElementError(
`'${workspaceAIElementFolder}' is empty. No AIElement files found for workspace '${workspaceId}'.`,
);
}

const aiElements = await Promise.all(files
.filter((file) => path.extname(file) === '.json')
.map(async (file) => {
const jsonFile = path.join(workspaceAIElementFolder, file);
const baseName = path.basename(jsonFile, '.json');
const pngFile = path.join(workspaceAIElementFolder, `${baseName}.png`);
if (await fs.pathExists(pngFile)) {
const metadata: AIElementJson = JSON.parse(await fs.readFile(jsonFile, 'utf-8'));
return AIElement.fromJson(metadata, pngFile);
}
return null;
}));

const validAIElements = aiElements.filter((element): element is AIElement => element !== null);

if (validAIElements.length === 0) {
throw new AIElementError(
`No AIElement files found for workspace '${workspaceId}' at '${workspaceAIElementFolder}'.`,
);
}

return new AIElementCollection(validAIElements);
}

getByName(name: string): CustomElementJson[] {
if (name === '') {
throw new AIElementError("Parameter 'name' must be non-empty. This might be due to corrupted metadata.");
}

logger.debug(`Getting all CustomElementJson with the name '${name}' ...`);

const elements = this.elements.filter((element) => element.hasName(name));
if (elements.length === 0) {
throw new AIElementError(`No AIElement with the name '${name}' was found.`);
}

return elements.map((element) => element.toCustomElement());
}

getByNames(names: string[]): CustomElementJson[] {
if (names.length === 0) {
return [];
}
return names.flatMap((name) => this.getByName(name));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class AIElementError extends Error { }
69 changes: 69 additions & 0 deletions packages/askui-nodejs/src/core/ai-element/ai-element.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { AIElement, AIElementJson } from './ai-element';
import { CustomElementJson } from '../model/custom-element-json';

describe('AIElement', () => {
const dummyAIElementMetadata: AIElementJson = {
image: {
mask: [
{ x: 1, y: 1 },
{ x: 2, y: 2 },
],
},
name: 'test-element',
version: 1,
};
const dummyImagePath = '/path/to/image.png';

it('should create an AIElement from a valid JSON', () => {
const element = AIElement.fromJson(dummyAIElementMetadata, dummyImagePath);
expect(element).toBeInstanceOf(AIElement);
expect(element.name).toBe(dummyAIElementMetadata.name);
expect(element.imagePath).toBe(dummyImagePath);
expect(element.mask).toEqual(dummyAIElementMetadata.image?.mask);
});

it('should throw an error for unsupported version', () => {
const invalidMetadata = { ...dummyAIElementMetadata, version: 2 };
expect(() => AIElement.fromJson(invalidMetadata, dummyImagePath)).toThrow(
'Unsupported AIElement version',
);
});

it('should convert AIElement to CustomElementJson', () => {
const element = new AIElement(
dummyAIElementMetadata.name,
dummyImagePath,
dummyAIElementMetadata.image?.mask,
);
const customElement = element.toCustomElement();
expect(customElement).toEqual<CustomElementJson>({
customImage: dummyImagePath,
mask: dummyAIElementMetadata.image?.mask,
name: dummyAIElementMetadata.name,
});
});

it('should convert AIElement without a mask to CustomElementJson without a mask', () => {
const element = new AIElement(
dummyAIElementMetadata.name,
dummyImagePath,
undefined,
);
const customElement = element.toCustomElement();
expect(customElement).toEqual<CustomElementJson>({
customImage: dummyImagePath,
mask: undefined,
name: dummyAIElementMetadata.name,
});
});

it('should check if element has a specific name', () => {
const element = new AIElement(
dummyAIElementMetadata.name,
dummyImagePath,
dummyAIElementMetadata.image?.mask,
);
expect(element.hasName(dummyAIElementMetadata.name)).toBe(true);
expect(element.hasName('other-name')).toBe(false);
});
});
39 changes: 39 additions & 0 deletions packages/askui-nodejs/src/core/ai-element/ai-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { CustomElementJson } from '../model/custom-element-json';
import { logger } from '../../lib';
import { AIElementError } from './ai-element-error';

interface AIElementJson {
version: number;
name: string;
image?: { mask?: { x: number; y: number }[] };
}

class AIElement {
constructor(
public name: string,
public imagePath: string,
public mask?: { x: number; y: number }[],
) {}

static fromJson(json: AIElementJson, imagePath: string): AIElement {
if (json.version === 1) {
return new AIElement(json.name, imagePath, json.image?.mask);
}
throw new AIElementError(`Unsupported AIElement version '${json.version}'.`);
}

toCustomElement(): CustomElementJson {
logger.debug('Converting AIElement to CustomElementJson.');
return {
customImage: this.imagePath,
mask: this.mask,
name: this.name,
};
}

hasName(name: string): boolean {
return this.name === name;
}
}

export { AIElement, AIElementJson };
133 changes: 100 additions & 33 deletions packages/askui-nodejs/src/execution/dsl.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { CustomElementJson } from '../core/model/custom-element-json';
import { FluentCommand } from './dsl';
import { CommandExecutorContext, FluentCommand } from './dsl';

class TestCommand extends FluentCommand {
// eslint-disable-next-line class-methods-use-this
async fluentCommandExecutor(
_instruction: string,
_customElements: CustomElementJson[],
_context: CommandExecutorContext,
): Promise<void> {
return Promise.resolve();
}
Expand All @@ -17,63 +16,131 @@ describe('DSL', () => {
const underTest = new TestCommand();
const testCommandSpy = jest.spyOn(underTest, 'fluentCommandExecutor');

await underTest.click().button()
.exec();
expect(testCommandSpy).toHaveBeenCalledWith(
'Click on button',
[],
);
await underTest.click().button().exec();
expect(testCommandSpy).toHaveBeenCalledWith('Click on button', { aiElementNames: [], customElementsJson: [] });
});

test('should call exec function with one custom element', async () => {
const underTest = new TestCommand();
const testCommandSpy = jest.spyOn(underTest, 'fluentCommandExecutor');

await underTest.click().customElement({
customImage: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 1',
}).button()
await underTest
.click()
.customElement({
customImage:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 1',
})
.button()
.exec();
expect(testCommandSpy).toHaveBeenCalledWith(
'Click on custom element button',
[{
customImage: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 1',
}],
{
aiElementNames: [],
customElementsJson: [
{
customImage:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 1',
},
],
},
);
});

test('should call exec function with two custom element', async () => {
const underTest = new TestCommand();
const testCommandSpy = jest.spyOn(underTest, 'fluentCommandExecutor');

await underTest.click().customElement({
customImage: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 1',
})
await underTest
.click()
.customElement({
customImage:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 1',
})
.button()
.customElement({
customImage: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
customImage:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 2',
})
.exec();
expect(testCommandSpy).toHaveBeenCalledWith(
'Click on custom element button custom element',
[{
customImage: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 1',
{
aiElementNames: [],
customElementsJson: [
{
customImage:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 1',
},
{
customImage:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 2',
},
],
},
);
});

test('should call exec function with one ai element', async () => {
const underTest = new TestCommand();
const testCommandSpy = jest.spyOn(underTest, 'fluentCommandExecutor');

await underTest
.click()
.aiElement('ai element')
.below()
.button()
.rightOf()
.customElement({
customImage:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
name: 'custom element',
})
.exec();
expect(testCommandSpy).toHaveBeenCalledWith(
'Click on ai element with name <|string|>ai element<|string|> index 0 below button index 0 right of custom element',
{
customImage: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
imageCompareFormat: 'grayscale',
name: 'custom element 2',
aiElementNames: ['ai element'],
customElementsJson: [
{
customImage:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==',
name: 'custom element',
},
],
},
);
});

test('should call exec function with two ai element', async () => {
const underTest = new TestCommand();
const testCommandSpy = jest.spyOn(underTest, 'fluentCommandExecutor');

await underTest
.click()
.aiElement('ai element')
.below()
.button()
.rightOf()
.aiElement('ai element 2')
.exec();
expect(testCommandSpy).toHaveBeenCalledWith(
'Click on ai element with name <|string|>ai element<|string|> index 0 below button index 0 right of ai element with name <|string|>ai element 2<|string|>',
{
aiElementNames: ['ai element', 'ai element 2'],
customElementsJson: [],
},
],
);
});
});
Expand Down
Loading

0 comments on commit ae59ba0

Please sign in to comment.