diff --git a/app/components/Input.tsx b/app/components/Input.tsx index 252c06d8..11f9fdaa 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -145,6 +145,14 @@ export default function Input({ value, onValueChange, handleSubmit, graph, icon, value={value || ""} onChange={(e) => { const newVal = e.target.value + const invalidChars = /[%*()\-\[\]{};:"|~]/; + + if (invalidChars.test(newVal)) { + e.target.setCustomValidity(`The character "${newVal.match(invalidChars)?.[0]}" is not allowed in this field.`); + e.target.reportValidity(); + return; + } + e.target.setCustomValidity(''); onValueChange({ name: newVal }) }} {...props} diff --git a/e2e/config/testData.ts b/e2e/config/testData.ts index 2e00ab9c..8e89a4f0 100644 --- a/e2e/config/testData.ts +++ b/e2e/config/testData.ts @@ -10,6 +10,6 @@ const categorizeCharacters = (characters: string[], expectedRes: boolean): { cha }; export const specialCharacters: { character: string; expectedRes: boolean }[] = [ - ...categorizeCharacters(['%', '*', '(', ')', '-', '[', ']', '{', '}', ';', ':', '"', '|', '~'], true), - ...categorizeCharacters(['!', '@', '$', '^', '_', '=', '+', "'", ',', '.', '<', '>', '/', '?', '\\', '`', '&', '#'], false) + ...categorizeCharacters(['%', '*', '(', ')', '-', '[', ']', '{', '}', ';', ':', '"', '|', '~'], false), + ...categorizeCharacters(['!', '@', '$', '^', '_', '=', '+', "'", ',', '.', '<', '>', '/', '?', '\\', '`', '&', '#'], true) ]; diff --git a/e2e/logic/POM/codeGraph.ts b/e2e/logic/POM/codeGraph.ts index df1746a5..3f8e3d60 100644 --- a/e2e/logic/POM/codeGraph.ts +++ b/e2e/logic/POM/codeGraph.ts @@ -1,6 +1,7 @@ import { Locator, Page } from "playwright"; import BasePage from "../../infra/ui/basePage"; -import { waitToBeEnabled } from "../utils"; +import { delay, waitToBeEnabled } from "../utils"; +import { analyzeCanvasWithLocator, CanvasAnalysisResult } from "../canvasAnalysis"; export default class CodeGraph extends BasePage { /* NavBar Locators*/ @@ -113,6 +114,28 @@ export default class CodeGraph extends BasePage { private get notificationError(): Locator { return this.page.locator("//div[@role='region']//ol//li"); } + + /* Canvas Locators*/ + + private get canvasElement(): Locator { + return this.page.locator("//canvas[position()=3]"); + } + + private get zoomInBtn(): Locator { + return this.page.locator("//button[@title='Zoom In']"); + } + + private get zoomOutBtn(): Locator { + return this.page.locator("//button[@title='Zoom Out']"); + } + + private get centerBtn(): Locator { + return this.page.locator("//button[@title='Center']"); + } + + private get removeNodeViaElementMenu(): Locator { + return this.page.locator("//button[@title='Remove']"); + } /* NavBar functionality */ async clickOnFalkorDbLogo(): Promise { @@ -253,6 +276,36 @@ export default class CodeGraph extends BasePage { return await this.searchBarList.evaluate((element) => { return element.scrollTop + element.clientHeight >= element.scrollHeight; }); - } + } + + /* Canvas functionality */ + + async getCanvasAnalysis(): Promise { + await delay(2000); + return await analyzeCanvasWithLocator(this.canvasElement); + } + + async clickZoomIn(): Promise { + await this.zoomInBtn.click(); + } + + async clickZoomOut(): Promise { + await this.zoomOutBtn.click(); + } + + async clickCenter(): Promise { + await this.centerBtn.click(); + } + + async clickOnRemoveNodeViaElementMenu(): Promise { + await this.removeNodeViaElementMenu.click(); + } + + async rightClickOnNode(x : number, y: number): Promise { + const boundingBox = (await this.canvasElement.boundingBox())!; + const adjustedX = boundingBox.x + Math.round(x); + const adjustedY = boundingBox.y + Math.round(y); + await this.page.mouse.click(adjustedX, adjustedY, { button: 'right' }); + } } diff --git a/e2e/logic/canvasAnalysis.ts b/e2e/logic/canvasAnalysis.ts new file mode 100644 index 00000000..e449f64f --- /dev/null +++ b/e2e/logic/canvasAnalysis.ts @@ -0,0 +1,158 @@ +import { Locator } from "@playwright/test"; + +export type Pixel = { x: number; y: number }; + +export interface CanvasAnalysisResult { + red: Array<{ x: number; y: number; radius: number }>; + yellow: Array<{ x: number; y: number; radius: number }>; + green: Array<{ x: number; y: number; radius: number }>; +} + +export async function analyzeCanvasWithLocator(locator: Locator) { + const canvasHandle = await locator.evaluateHandle((canvas) => canvas as HTMLCanvasElement); + const canvasElement = await canvasHandle.asElement(); + if (!canvasElement) { + throw new Error("Failed to retrieve canvas element"); + } + + // Retrieve the original canvas width + const originalCanvasWidth = await canvasElement.evaluate((canvas) => canvas.width); + + const result = await canvasElement.evaluate( + (canvas, originalWidth) => { + const ctx = canvas?.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get 2D context"); + } + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const { data, width, height } = imageData; + + const scaleFactor = canvas.width / originalWidth; + const adjustedRadius = 3 / scaleFactor; + const adjustedMergeRadius = 10 / scaleFactor; + + type Pixel = { x: number; y: number }; + + const redPixels: Pixel[] = []; + const yellowPixels: Pixel[] = []; + const greenPixels: Pixel[] = []; + + const isRedPixel = (r: number, g: number, b: number) => r > 170 && g < 120 && b < 120; + const isYellowPixel = (r: number, g: number, b: number) => r > 170 && g > 170 && b < 130; + const isGreenPixel = (r: number, g: number, b: number) => g > 120 && g > r && g > b && r < 50 && b < 160; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + if (isRedPixel(r, g, b)) redPixels.push({ x, y }); + if (isYellowPixel(r, g, b)) yellowPixels.push({ x, y }); + if (isGreenPixel(r, g, b)) greenPixels.push({ x, y }); + } + } + + const clusterNodes = (pixels: Pixel[], radius: number): Pixel[][] => { + const visited = new Set(); + const clusters: Pixel[][] = []; + + pixels.forEach((pixel) => { + const key = `${pixel.x},${pixel.y}`; + if (visited.has(key)) return; + + const cluster: Pixel[] = []; + const stack: Pixel[] = [pixel]; + + while (stack.length > 0) { + const current = stack.pop()!; + const currentKey = `${current.x},${current.y}`; + if (visited.has(currentKey)) continue; + + visited.add(currentKey); + cluster.push(current); + + pixels.forEach((neighbor) => { + const dist = Math.sqrt( + (current.x - neighbor.x) ** 2 + (current.y - neighbor.y) ** 2 + ); + if (dist <= radius) stack.push(neighbor); + }); + } + + clusters.push(cluster); + }); + + return clusters; + }; + + const mergeCloseClusters = (clusters: Pixel[][], mergeRadius: number): Pixel[][] => { + const mergedClusters: Pixel[][] = []; + const used = new Set(); + + for (let i = 0; i < clusters.length; i++) { + if (used.has(i)) continue; + + let merged = [...clusters[i]]; + for (let j = i + 1; j < clusters.length; j++) { + if (used.has(j)) continue; + + const dist = Math.sqrt( + (merged[0].x - clusters[j][0].x) ** 2 + + (merged[0].y - clusters[j][0].y) ** 2 + ); + + if (dist <= mergeRadius) { + merged = merged.concat(clusters[j]); + used.add(j); + } + } + + mergedClusters.push(merged); + used.add(i); + } + + return mergedClusters; + }; + + const redClusters = clusterNodes(redPixels, adjustedRadius); + const yellowClusters = clusterNodes(yellowPixels, adjustedRadius); + const greenClusters = clusterNodes(greenPixels, adjustedRadius); + + const mergedGreenClusters = mergeCloseClusters(greenClusters, adjustedMergeRadius); + + const filteredRedClusters = redClusters.filter((cluster) => cluster.length >= 5); + const filteredYellowClusters = yellowClusters.filter((cluster) => cluster.length >= 5); + const filteredGreenClusters = mergedGreenClusters.filter((cluster) => cluster.length >= 5); + + const calculateRadius = (cluster: Pixel[], scaleFactor: number) => { + const rawRadius = Math.sqrt(cluster.length / Math.PI) / scaleFactor; + return Math.round(rawRadius * 1000) / 1000; + }; + + return { + red: filteredRedClusters.map(cluster => ({ + x: cluster.reduce((sum, p) => sum + p.x, 0) / cluster.length, + y: cluster.reduce((sum, p) => sum + p.y, 0) / cluster.length, + radius: calculateRadius(cluster, scaleFactor) + })), + yellow: filteredYellowClusters.map(cluster => ({ + x: cluster.reduce((sum, p) => sum + p.x, 0) / cluster.length, + y: cluster.reduce((sum, p) => sum + p.y, 0) / cluster.length, + radius: calculateRadius(cluster, scaleFactor) + })), + green: filteredGreenClusters.map(cluster => ({ + x: cluster.reduce((sum, p) => sum + p.x, 0) / cluster.length, + y: cluster.reduce((sum, p) => sum + p.y, 0) / cluster.length, + radius: calculateRadius(cluster, scaleFactor) + })) + }; + }, + originalCanvasWidth + ); + + await canvasHandle.dispose(); + return result; +} \ No newline at end of file diff --git a/e2e/tests/codeGraph.spec.ts b/e2e/tests/codeGraph.spec.ts index 6882c9fc..f2eaa60f 100644 --- a/e2e/tests/codeGraph.spec.ts +++ b/e2e/tests/codeGraph.spec.ts @@ -5,6 +5,9 @@ import urls from "../config/urls.json"; import { GRAPH_ID } from "../config/constants"; import { delay } from "../logic/utils"; import { searchData, specialCharacters } from "../config/testData"; +import { CanvasAnalysisResult } from "../logic/canvasAnalysis"; + +const colors: (keyof CanvasAnalysisResult)[] = ["red", "yellow", "green"]; test.describe("Code graph tests", () => { let browser: BrowserWrapper; @@ -59,7 +62,68 @@ test.describe("Code graph tests", () => { await codeGraph.selectGraph(GRAPH_ID); await codeGraph.fillSearchBar(character); await delay(1000); - expect(await codeGraph.isNotificationError()).toBe(expectedRes); + expect((await codeGraph.getSearchBarInputValue()).includes(character)).toBe(expectedRes); }); }); + + test(`Verify zoom in functionality on canvas`, async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPH_ID); + const initialNodeAnalysis = await codeGraph.getCanvasAnalysis(); + await codeGraph.clickZoomIn(); + await codeGraph.clickZoomIn(); + const updatedNodeAnalysis = await codeGraph.getCanvasAnalysis(); + for (const color of colors) { + const initialRadius = initialNodeAnalysis[color][0].radius; + const updatedRadius = updatedNodeAnalysis[color][0].radius; + expect(initialRadius).toBeDefined(); + expect(updatedRadius).toBeDefined(); + expect(updatedRadius).toBeGreaterThan(initialRadius); + } + }) + + test(`Verify zoom out functionality on canvas`, async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPH_ID); + const initialNodeAnalysis = await codeGraph.getCanvasAnalysis(); + for (let i = 0; i < 5; i++) { + await codeGraph.clickZoomOut(); + } + const updatedNodeAnalysis = await codeGraph.getCanvasAnalysis(); + for (const color of colors) { + const initialRadius = initialNodeAnalysis[color][0].radius; + const updatedRadius = updatedNodeAnalysis[color][0].radius; + expect(initialRadius).toBeDefined(); + expect(updatedRadius).toBeDefined(); + expect(updatedRadius).toBeLessThan(initialRadius); + } + }) + + test(`Verify center graph button centers nodes in canvas`, async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPH_ID); + const initialNodeAnalysis = await codeGraph.getCanvasAnalysis(); + await codeGraph.clickZoomIn(); + await codeGraph.clickZoomIn(); + await codeGraph.clickCenter(); + const updatedNodeAnalysis = await codeGraph.getCanvasAnalysis(); + for (const color of colors) { + const initialRadius = Math.round(initialNodeAnalysis[color][0].radius); + const updatedRadius = Math.round(updatedNodeAnalysis[color][0].radius); + expect(initialRadius).toBeDefined(); + expect(updatedRadius).toBeDefined(); + expect(updatedRadius).toBeCloseTo(initialRadius); + } + }) + + test(`Validate node removal functionality via element menu in canvas`, async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPH_ID); + const initialNodeAnalysis = await codeGraph.getCanvasAnalysis(); + await codeGraph.rightClickOnNode(initialNodeAnalysis.red[0].x, initialNodeAnalysis.red[0].y); + await codeGraph.clickOnRemoveNodeViaElementMenu(); + const updatedNodeAnalysis = await codeGraph.getCanvasAnalysis(); + expect(initialNodeAnalysis.red.length).toBeGreaterThan(updatedNodeAnalysis.red.length); + }); + });